← каталог
Text Particles
Надпись, собранная из частиц: они разлетаются по щелчку и стягиваются обратно. Форму букв процессор считывает один раз с закадрового 2D-холста, дальше он их не трогает вовсе — обе позиции лежат на видеокарте атрибутами, а кадр меняет ровно одно число, и интерполирует вершинный шейдер.
oglclickloadstaggeradvanced~11 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| text? | string | "МОТИВА" | Надпись, которую собирают частицы. |
| step? | number | 4 | Шаг выборки по глифам, px: меньше — плотнее и дороже (формат «4»). |
| size? | number | 3.4 | Размер частицы, px (формат «3.4»). |
| duration? | number | 1.6 | Секунд на сборку (формат «1.6»). |
| hue? | number | 0.86 | Тон в круге, 0–1 (формат «0.86»). |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/text-particles.jsonзависимости: ogl
Что нужно в проекте до установки
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";
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