← каталог
ASCII Text
Слова и цифры из ASCII-символов: поле шума, из которого проступают буквы. Форма берётся с обычного fillText в крошечный закадровый холст, поэтому подходит любой шрифт и любой текст — без таблицы глифов.
cssautorevealstaggeradvanced~3 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| words? | string[] | WORDS | Слова, которые сменяют друг друга (формат «["MOTIVA","2026"]»). |
| cols? | number | 130 | Символов по ширине (формат «130»). |
| hold? | number | 2.6 | Сколько слово держится, сек (формат «2.6»). |
| reveal? | number | 1 | Длительность проявления, сек (формат «1»). |
| hue? | number | 300 | Тон в oklch, 0–360 (формат «300»). |
| noise? | string | "01#*+=-:." | Символы «шума» до проявления. |
| fill? | string | — | Символы тела буквы — перебираются по клеткам (формат «@#%&$»). |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/ascii-text.jsonЧто нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef } from "react";
export interface AsciiTextProps {
/** Слова, которые сменяют друг друга (формат «["MOTIVA","2026"]»). */
words?: string[];
/** Символов по ширине (формат «130»). */
cols?: number;
/** Сколько слово держится, сек (формат «2.6»). */
hold?: number;
/** Длительность проявления, сек (формат «1»). */
reveal?: number;
/** Тон в oklch, 0–360 (формат «300»). */
hue?: number;
/** Символы «шума» до проявления. */
noise?: string;
/** Символы тела буквы — перебираются по клеткам (формат «@#%&$»). */
fill?: string;
}
const WORDS = ["MOTIVA", "2026", "ASCII"];
/**
* Подложка тёмная в ОБЕИХ темах, и это осознанно. ASCII — терминальная эстетика: на светлом
* фоне тёмные участки картинки дают пробел, а светлые — светлый символ, и изображение
* пропадает целиком. Тот же принцип, что у блоков кода: они тоже остаются тёмными везде.
*/
const DARK = "oklch(0.12 0.02 275)";
/**
* Слова и цифры, собранные из ASCII-символов: сначала поле шума, потом буквы проступают.
*
* Форму букв НЕ рисуем руками и не храним шрифтом-матрицей. Слово один раз выводится
* обычным `fillText` в крошечный закадровый холст размером ровно в сетку символов, оттуда
* читается маска: «в этой клетке есть чернила или нет». Дальше сетка живёт своей жизнью.
* Отсюда два следствия: подходит ЛЮБОЙ шрифт и любой текст — включая цифры, кириллицу
* и эмодзи, — и никакой таблицы глифов в коде.
*
* Проявление сделано без единого таймера на клетку. У каждой клетки есть свой порог,
* посчитанный один раз из расстояния до центра и капли случайности; в кадре сравнивается
* общий прогресс с этим порогом. Тысяча клеток — тысяча сравнений, а не тысяча таймеров.
*
* Пересэмплирование маски происходит только при смене слова или размера. Шум в кадре —
* это выбор символа по индексу и времени, без чтения пикселей.
*/
export function AsciiText({
words = WORDS,
cols = 130,
hold = 2.6,
reveal = 1,
hue = 300,
noise = "01#*+=-:.",
// тело буквы — ПЛОТНЫЕ ASCII-символы, а не блок «█». Блок и не ASCII, и на экране
// смыкается с соседями в сплошную плашку: получалась пиксельная надпись, а не набранная
fill = "@#%&$",
}: AsciiTextProps = {}) {
const host = useRef<HTMLDivElement>(null);
const cfg = useRef({ words, cols, hold, reveal, hue, noise, fill });
cfg.current = { words, cols, hold, reveal, hue, noise, fill };
useEffect(() => {
const el = host.current;
if (!el) return;
const canvas = document.createElement("canvas");
Object.assign(canvas.style, { position: "absolute", inset: "0", width: "100%", height: "100%" });
el.appendChild(canvas);
const ctx = canvas.getContext("2d");
if (!ctx) return;
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const small = document.createElement("canvas");
const sctx = small.getContext("2d", { willReadFrequently: true })!;
let gw = 0;
let gh = 0;
let cellW = 0;
let cellH = 0;
let fontPx = 0;
let mask = new Uint8Array(0);
let threshold = new Float32Array(0);
let word = 0;
const mono = () =>
getComputedStyle(el).getPropertyValue("--ds-font-mono") || "ui-monospace, monospace";
/**
* Ширина символа — доля от кегля, и у разных моноширинных шрифтов она разная (0.5–0.62).
* Меряем, а не берём «примерно вдвое выше своей ширины»: из этой доли следует и кегль,
* и высота строки, и вертикальное сжатие маски.
*/
const advance = () => {
sctx.font = `100px ${mono()}`;
return (sctx.measureText("M").width || 60) / 100;
};
/** Одноразовая тяжёлая часть: вывести слово и снять с него маску. */
const sample = () => {
const c = cfg.current;
const w = el.clientWidth;
const h = el.clientHeight;
if (!w || !h) return;
// символ мельче ~6 px перестаёт читаться как ЗНАК и вырождается в дизеринг, а вместе
// с ним пропадает и смысл записи. Поэтому на узком экране сетка РЕДЕЕТ, а не мельчает:
// число колонок ограничено шириной кадра, а не только пропсом
gw = Math.max(12, Math.min(Math.round(c.cols), Math.floor(w / 6)));
cellW = w / gw;
const adv = advance();
fontPx = cellW / adv; // символ занимает ровно клетку по ширине
cellH = fontPx;
gh = Math.max(4, Math.floor(h / cellH));
small.width = gw;
small.height = gh;
sctx.clearRect(0, 0, gw, gh);
sctx.fillStyle = "#fff";
sctx.textAlign = "center";
sctx.textBaseline = "middle";
const text = c.words[word % c.words.length] ?? "";
// Клетка выше, чем шире, поэтому слово в маску пишется СЖАТЫМ по вертикали ровно
// на ту же долю: на экране каждая строка растягивается обратно, и буквы выходят
// с верными пропорциями. Без сжатия слово вытягивалось вверх в ~1.7 раза.
sctx.setTransform(1, 0, 0, adv, 0, 0);
const font = getComputedStyle(el).fontFamily;
let size = (gh * 0.9) / adv;
sctx.font = `700 ${size}px ${font}`;
const measured = sctx.measureText(text).width || 1;
size = Math.min(size, size * ((gw * 0.86) / measured));
sctx.font = `700 ${size}px ${font}`;
sctx.fillText(text, gw / 2, gh / 2 / adv);
sctx.setTransform(1, 0, 0, 1, 0, 0);
const data = sctx.getImageData(0, 0, gw, gh).data;
mask = new Uint8Array(gw * gh);
threshold = new Float32Array(gw * gh);
const cx = gw / 2;
const cy = gh / 2;
const maxD = Math.hypot(cx, cy) || 1;
for (let i = 0, p = 3; i < mask.length; i++, p += 4) {
mask[i] = data[p]! > 90 ? 1 : 0;
const x = i % gw;
const y = (i / gw) | 0;
// порог клетки: ближе к центру — раньше; щепотка случайности убирает «круг»
threshold[i] = (Math.hypot(x - cx, y - cy) / maxD) * 0.75 + Math.random() * 0.25;
}
};
let dpr = 1;
const resize = () => {
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(el.clientWidth * dpr);
canvas.height = Math.round(el.clientHeight * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
sample();
};
resize();
let t = 0;
let phase = 0; // 0 — проявление, 1 — держим, 2 — растворение
const draw = () => {
const c = cfg.current;
const w = el.clientWidth;
const h = el.clientHeight;
ctx.clearRect(0, 0, w, h);
if (!gw) return;
ctx.font = `${fontPx}px ${mono()}`;
ctx.textBaseline = "top";
ctx.textAlign = "left";
// прогресс: 0 — только шум, 1 — слово целиком
const p = frozen ? 1 : phase === 0 ? Math.min(1, t / c.reveal) : phase === 1 ? 1 : Math.max(0, 1 - t / (c.reveal * 0.6));
const offX = (w - gw * cellW) / 2;
const offY = (h - gh * cellH) / 2;
// fillStyle назначается только на СМЕНЕ цвета: стилей всего три, а клеток тысячи,
// и каждое присваивание — разбор строки цвета движком. Клетки идут длинными
// одноцветными сериями, поэтому проверка окупается сразу
let cur = "";
for (let i = 0; i < mask.length; i++) {
const x = i % gw;
const y = (i / gw) | 0;
const on = mask[i] === 1 && p > threshold[i]!;
let ch: string;
let alpha: number;
if (on) {
// символ тела выбирается по клетке — буква остаётся НАБРАННОЙ из разных
// плотных знаков, а не залитой одним
ch = c.fill[(x * 7 + y * 3) % c.fill.length] ?? "@";
alpha = 0.92;
} else {
// шум: символ выбирается по индексу и времени — ни массива, ни таймеров
const k = (i * 2654435761 + ((t * 12) | 0) * 40503) >>> 0;
ch = c.noise[k % c.noise.length] ?? ".";
alpha = mask[i] === 1 ? 0.35 : 0.12;
}
const style = on
? `oklch(0.82 0.17 ${c.hue} / ${alpha})`
: `oklch(0.62 0.05 ${c.hue} / ${alpha})`;
if (style !== cur) {
cur = style;
ctx.fillStyle = style;
}
ctx.fillText(ch, offX + x * cellW, offY + y * cellH);
}
};
let raf = 0;
let last = 0;
const loop = (now: number) => {
const c = cfg.current;
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
t += dt;
if (phase === 0 && t >= c.reveal) {
phase = 1;
t = 0;
} else if (phase === 1 && t >= c.hold) {
phase = 2;
t = 0;
} else if (phase === 2 && t >= c.reveal * 0.6) {
phase = 0;
t = 0;
word++;
sample();
}
draw();
raf = requestAnimationFrame(loop);
};
const start = () => {
if (raf || frozen) return;
last = performance.now();
raf = requestAnimationFrame(loop);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
draw();
const ro = new ResizeObserver(() => {
resize();
draw();
});
ro.observe(el);
const io = new IntersectionObserver(([e]) => (e!.isIntersecting && !document.hidden ? start() : stop()));
io.observe(el);
const onVis = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVis);
return () => {
stop();
io.disconnect();
ro.disconnect();
document.removeEventListener("visibilitychange", onVis);
canvas.remove();
};
}, []);
return (
<div
ref={host}
className="ast"
style={{
position: "absolute",
inset: 0,
overflow: "hidden",
background: DARK,
fontFamily: "var(--ds-font, system-ui, sans-serif)",
}}
/>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0