Motiva
Мои коллекции
← каталог

ASCII Text

Слова и цифры из ASCII-символов: поле шума, из которого проступают буквы. Форма берётся с обычного fillText в крошечный закадровый холст, поэтому подходит любой шрифт и любой текст — без таблицы глифов.

cssautorevealstaggeradvanced~3 КБreduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
words?string[]WORDSСлова, которые сменяют друг друга (формат «["MOTIVA","2026"]»).
cols?number130Символов по ширине (формат «130»).
hold?number2.6Сколько слово держится, сек (формат «2.6»).
reveal?number1Длительность проявления, сек (формат «1»).
hue?number300Тон в oklch, 0–360 (формат «300»).
noise?string"01#*+=-:."Символы «шума» до проявления.
fill?stringСимволы тела буквы — перебираются по клеткам (формат «@#%&$»).

Установка

npx shadcn@latest add https://motiva.pages.dev/r/ascii-text.json
Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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