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

Text Particles

Надпись, собранная из частиц: они разлетаются по щелчку и стягиваются обратно. Форму букв процессор считывает один раз с закадрового 2D-холста, дальше он их не трогает вовсе — обе позиции лежат на видеокарте атрибутами, а кадр меняет ровно одно число, и интерполирует вершинный шейдер.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
text?string"МОТИВА"Надпись, которую собирают частицы.
step?number4Шаг выборки по глифам, px: меньше — плотнее и дороже (формат «4»).
size?number3.4Размер частицы, px (формат «3.4»).
duration?number1.6Секунд на сборку (формат «1.6»).
hue?number0.86Тон в круге, 0–1 (формат «0.86»).

Установка

npx shadcn@latest add https://motiva.pages.dev/r/text-particles.json

зависимости: ogl

Что нужно в проекте до установки
  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";
import { Geometry, Mesh, Program, Renderer } from "ogl";

export interface TextParticlesProps {
  /** Надпись, которую собирают частицы. */
  text?: string;
  /** Шаг выборки по глифам, px: меньше — плотнее и дороже (формат «4»). */
  step?: number;
  /** Размер частицы, px (формат «3.4»). */
  size?: number;
  /** Секунд на сборку (формат «1.6»). */
  duration?: number;
  /** Тон в круге, 0–1 (формат «0.86»). */
  hue?: number;
}

const VERT = /* glsl */ `
  attribute vec2 position;   // где частица стоит в СОБРАННОЙ надписи, px
  attribute vec2 aScatter;   // где она же в рассыпанном состоянии, px
  attribute float aSeed;
  uniform vec2 uRes;
  uniform float uMix;        // 0 — собрано, 1 — рассыпано
  uniform float uSize;
  uniform float uDpr;
  varying float vSeed;
  varying float vMix;

  void main() {
    // у каждой частицы своя доля пути: разброс по семени даёт волну вместо строя
    float m = clamp(uMix * (1.0 + aSeed * 0.6) - aSeed * 0.3, 0.0, 1.0);
    vec2 p = mix(position, aScatter, m);
    vec2 clip = (p / uRes) * 2.0 - 1.0;
    gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
    gl_PointSize = uSize * uDpr * (1.0 + m * 0.9);
    vSeed = aSeed;
    vMix = m;
  }
`;

const FRAG = /* glsl */ `
  precision highp float;
  uniform float uHue;
  varying float vSeed;
  varying float vMix;

  vec3 hsv2rgb(vec3 c) {
    vec3 p = abs(fract(c.xxx + vec3(0.0, 2.0 / 3.0, 1.0 / 3.0)) * 6.0 - 3.0);
    return c.z * mix(vec3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y);
  }

  void main() {
    // круглая частица с мягким краем: квадратные точки читаются как пиксельный мусор
    float d = length(gl_PointCoord - 0.5);
    float a = smoothstep(0.5, 0.26, d);
    if (a < 0.01) discard;
    // рассыпанные холоднее и тусклее собранных — так видно, куда идёт движение
    vec3 col = hsv2rgb(vec3(fract(uHue + vSeed * 0.12 - vMix * 0.16), 0.55 - vMix * 0.2, 1.0));
    gl_FragColor = vec4(col, a * (1.0 - vMix * 0.45));
  }
`;

/**
 * Надпись, собранная из частиц: они разлетаются по щелчку и стягиваются обратно.
 *
 * Интересна здесь не картинка, а РАЗДЕЛЕНИЕ ТРУДА между процессором и видеокартой.
 *
 * Форму букв процессор считывает ОДИН раз: надпись рисуется в закадровый 2D-холст обычным
 * `fillText`, оттуда `getImageData` — и каждая непрозрачная точка с шагом в несколько
 * пикселей становится частицей. Отсюда два следствия: годится любой шрифт и любой текст
 * (таблицы глифов нет вовсе), а цена — один проход, а не сорок кадров в секунду.
 *
 * Дальше процессор не трогает частицы ВООБЩЕ. Обе позиции — собранная и рассыпанная —
 * лежат в буферах на видеокарте как два атрибута, а кадр меняет ровно одно число: `uMix`.
 * Интерполяцию между атрибутами делает вершинный шейдер, для каждой частицы параллельно.
 * Наивная версия перебирает те же шесть тысяч частиц в JS каждый кадр и пишет шесть тысяч
 * координат в буфер — здесь вместо этого одна запись uniform'а.
 *
 * Разлёт у каждой частицы начинается в своё время (сдвиг по семени в шейдере), иначе
 * надпись движется строем и читается как одна картинка, которую просто отодвинули.
 *
 * Цикл кадров засыпает, когда надпись собралась: интерполировать больше нечего.
 */
export function TextParticles({
  text = "МОТИВА",
  step = 4,
  size = 3.4,
  duration = 1.6,
  hue = 0.86,
}: TextParticlesProps) {
  const host = useRef<HTMLDivElement>(null);
  const burst = useRef<(() => void) | null>(null);
  const cfg = useRef({ text, step, size, duration, hue });
  cfg.current = { text, step, size, duration, hue };

  useEffect(() => {
    const el = host.current;
    if (!el) return;
    const frozen =
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const dpr = Math.min(2, window.devicePixelRatio || 1);
    const renderer = new Renderer({ alpha: true, antialias: false, dpr });
    const gl = renderer.gl;
    gl.clearColor(0, 0, 0, 0);
    Object.assign(gl.canvas.style, { display: "block", width: "100%", height: "100%" });
    el.appendChild(gl.canvas);

    const program = new Program(gl, {
      vertex: VERT,
      fragment: FRAG,
      transparent: true,
      depthTest: false,
      uniforms: {
        uRes: { value: [1, 1] },
        uMix: { value: 1 },
        uSize: { value: cfg.current.size },
        uDpr: { value: dpr },
        uHue: { value: cfg.current.hue },
      },
    });
    let mesh: Mesh | null = null;

    /** Снять форму букв с закадрового холста и разложить в два буфера позиций. */
    const sample = (w: number, h: number) => {
      const cv = document.createElement("canvas");
      cv.width = Math.max(1, Math.round(w));
      cv.height = Math.max(1, Math.round(h));
      const ctx = cv.getContext("2d", { willReadFrequently: true });
      if (!ctx) return null;

      // Кегль ПОДБИРАЕТСЯ замером, а не считается от числа букв. Прикидка «ширина кадра
      // на длину строки» промахивается на любом непривычном алфавите и начертании:
      // кириллические прописные шире латинских, и надпись уезжала за кромку.
      const family = getComputedStyle(el).fontFamily || "system-ui, sans-serif";
      let fs = Math.min(h * 0.44, w * 0.3);
      for (let i = 0; i < 12; i++) {
        ctx.font = `800 ${fs}px ${family}`;
        const wide = ctx.measureText(cfg.current.text).width;
        if (wide <= w * 0.82) break;
        fs *= (w * 0.82) / wide;
      }
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      ctx.fillStyle = "#fff";
      ctx.fillText(cfg.current.text, cv.width / 2, cv.height / 2);

      const data = ctx.getImageData(0, 0, cv.width, cv.height).data;
      const st = Math.max(2, Math.round(cfg.current.step));
      const target: number[] = [];
      const scatter: number[] = [];
      const seed: number[] = [];
      // кольцо разлёта меряем по МЕНЬШЕЙ стороне: по большей частицы улетают за кадр,
      // и на экране от рассыпанной надписи остаётся десяток точек вместо облака
      const R = Math.min(w, h);
      for (let y = 0; y < cv.height; y += st) {
        for (let x = 0; x < cv.width; x += st) {
          if (data[(y * cv.width + x) * 4 + 3]! < 128) continue;
          target.push(x, y);
          // рассыпаем по кольцу вокруг центра: равномерный квадрат даёт «облако»,
          // из которого не читается направление сборки
          const a = Math.random() * Math.PI * 2;
          const r = R * (0.3 + Math.random() * 0.45);
          scatter.push(w / 2 + Math.cos(a) * r * 1.4, h / 2 + Math.sin(a) * r * 0.85);
          seed.push(Math.random());
        }
      }
      if (!target.length) return null;
      return {
        position: new Float32Array(target),
        aScatter: new Float32Array(scatter),
        aSeed: new Float32Array(seed),
      };
    };

    const build = (w: number, h: number) => {
      const s = sample(w, h);
      if (!s) return;
      const geometry = new Geometry(gl, {
        position: { size: 2, data: s.position },
        aScatter: { size: 2, data: s.aScatter },
        aSeed: { size: 1, data: s.aSeed },
      });
      mesh = new Mesh(gl, { geometry, program, mode: gl.POINTS });
    };

    let raf = 0;
    let t0 = 0;
    let head = 0;

    /**
     * Одна кривая на весь приём: 0 → 1 за первую треть (разлёт, резкий) и 1 → 0 за
     * оставшиеся две (сборка, мягкая). Две отдельные анимации со «стыком» посередине дали
     * бы паузу ровно в тот момент, когда взгляд ждёт продолжения.
     */
    const curve = (t: number) =>
      t < 0.3 ? 1 - Math.pow(1 - t / 0.3, 3) : Math.pow(1 - (t - 0.3) / 0.7, 3);

    const draw = () => {
      program.uniforms.uSize!.value = cfg.current.size;
      program.uniforms.uHue!.value = cfg.current.hue;
      if (mesh) renderer.render({ scene: mesh });
    };

    const tick = (now: number) => {
      const dur = Math.max(0.2, cfg.current.duration) * 1000;
      const t = Math.min(1, head + (now - t0) / dur);
      program.uniforms.uMix!.value = curve(t);
      draw();
      raf = t < 1 ? requestAnimationFrame(tick) : 0; // собралось — интерполировать нечего
    };

    /** `from` = 0 — разлёт и сборка, 0.3 — только сборка (появление записи). */
    const play = (fromT: number) => {
      if (frozen) {
        program.uniforms.uMix!.value = 0;
        draw();
        return;
      }
      cancelAnimationFrame(raf);
      head = fromT;
      t0 = performance.now();
      raf = requestAnimationFrame(tick);
    };
    burst.current = () => play(0);

    const resize = () => {
      const { clientWidth: w, clientHeight: h } = el;
      if (!w || !h) return;
      renderer.setSize(w, h);
      program.uniforms.uRes!.value = [w, h];
      build(w, h);
      draw();
    };

    // шрифт может доехать позже первого кадра, и тогда форма снимется с запасного —
    // буквы окажутся другой ширины, чем то, что человек в итоге увидит
    const boot = () => {
      resize();
      play(0.3); // при появлении только сборка: разлетаться ещё нечему
    };
    if (document.fonts?.ready) document.fonts.ready.then(boot).catch(boot);
    else boot();

    const ro = new ResizeObserver(() => {
      resize();
      program.uniforms.uMix!.value = 0;
      draw();
    });
    ro.observe(el);
    const onVis = () => document.hidden && cancelAnimationFrame(raf);
    document.addEventListener("visibilitychange", onVis);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      burst.current = null;
      gl.getExtension("WEBGL_lose_context")?.loseContext();
      gl.canvas.remove();
    };
  }, []);

  return (
    <div style={{ position: "absolute", inset: 0 }}>
      <div ref={host} className="tpa" style={{ position: "absolute", inset: 0 }} />
      {/* настоящая кнопка поверх сцены: приём запускается и с клавиатуры, и диктор
          объявляет его словами, а не «холст» */}
      <button
        type="button"
        onClick={() => burst.current?.()}
        style={{
          position: "absolute",
          inset: 0,
          border: 0,
          padding: 0,
          background: "none",
          cursor: "pointer",
          appearance: "none",
        }}
      >
        <span style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
          Рассыпать и собрать надпись «{text}»
        </span>
      </button>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — https://codepen.io/tomncurry/pen/wXKReQ (лицензия не указана, код не заимствован)v1.0.0