← каталог
Magnet Text
Надпись из частиц с НАСТОЯЩЕЙ физикой курсора: наведение расталкивает, зажатая кнопка притягивает, пружина возвращает буквы домой. Canvas 2D, все состояния в типизированных массивах, цикл засыпает в покое.
cssmouse-moveclickmagneticintermediate~3 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| text? | string | "МАГНИТ" | Надпись (формат «МАГНИТ»). |
| radius? | number | 110 | Радиус поля курсора, px (формат «110»). |
| force? | number | 2600 | Сила расталкивания, px/с² на единицу близости (формат «2600»). |
| spring? | number | 46 | Жёсткость пружины возврата домой (формат «46»). |
| gap? | number | 4 | Шаг сетки частиц, px: меньше — плотнее и дороже (формат «4»). |
| hue? | number | 300 | Тон в oklch, 0–360 (формат «300»). |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/magnet-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 MagnetTextProps {
/** Надпись (формат «МАГНИТ»). */
text?: string;
/** Радиус поля курсора, px (формат «110»). */
radius?: number;
/** Сила расталкивания, px/с² на единицу близости (формат «2600»). */
force?: number;
/** Жёсткость пружины возврата домой (формат «46»). */
spring?: number;
/** Шаг сетки частиц, px: меньше — плотнее и дороже (формат «4»). */
gap?: number;
/** Тон в oklch, 0–360 (формат «300»). */
hue?: number;
}
/**
* Надпись из частиц, с которой курсор взаимодействует ПО-НАСТОЯЩЕМУ: возит и притягивает,
* а не проигрывает заготовленный твин.
*
* Отличие от `text-particles` (OGL) — там позиции лежат на видеокарте атрибутами и кадр
* интерполирует вершинный шейдер: красиво и дёшево, но курсор для частиц не существует.
* Здесь у каждой частицы честная физика на процессоре: пружина к «дому» (пикселю буквы),
* вязкое трение и поле курсора — НАВЕДЕНИЕ расталкивает, ЗАЖАТАЯ кнопка притягивает.
* Несколько тысяч частиц на кадр — дешёвая арифметика без единого объекта в куче
* (все состояния в типизированных массивах).
*
* Форму букв процессор считывает ОДИН раз: fillText в закадровый холст + getImageData —
* годится любой шрифт и любой текст. Кегль подбирается ЗАМЕРОМ measureText (кириллица
* шире латиницы, прикидка «ширина на длину строки» уводит надпись за кромку).
*
* Цикл кадров засыпает сам: когда энергия системы упала и курсор ушёл — rAF останавливается,
* в покое запись не стоит ничего. Трение через exp(-k·dt) — не зависит от fps.
*/
export function MagnetText({
text = "МАГНИТ",
radius = 110,
force = 2600,
spring = 46,
gap = 4,
hue = 300,
}: MagnetTextProps = {}) {
const host = useRef<HTMLDivElement>(null);
const cfg = useRef({ text, radius, force, spring, gap, hue });
cfg.current = { text, radius, force, spring, gap, hue };
useEffect(() => {
const el = host.current;
if (!el) return;
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
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;
let dpr = 1;
// состояния частиц: дом, позиция, скорость — только типизированные массивы
let hx = new Float32Array(0);
let hy = new Float32Array(0);
let x = new Float32Array(0);
let y = new Float32Array(0);
let vx = new Float32Array(0);
let vy = new Float32Array(0);
let count = 0;
let seededText = "";
const build = () => {
const w = el.clientWidth;
const h = el.clientHeight;
if (!w || !h) return;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// кегль ЗАМЕРОМ: вписать надпись в 82% ширины и 60% высоты кадра
const probeSize = 100;
const off = document.createElement("canvas");
off.width = w;
off.height = h;
const octx = off.getContext("2d")!;
// canvas.font НЕ понимает var(): невалидная строка молча игнорируется, и текст
// рисуется дефолтными 10px — берём уже ВЫЧИСЛЕННОЕ семейство из DOM
const family = getComputedStyle(el).fontFamily || "system-ui, sans-serif";
const font = (s: number) => `800 ${s}px ${family}`;
octx.font = font(probeSize);
const m = octx.measureText(cfg.current.text);
const size = Math.max(24, Math.min((probeSize * w * 0.82) / Math.max(1, m.width), h * 0.6));
octx.font = font(size);
octx.textAlign = "center";
octx.textBaseline = "middle";
octx.fillStyle = "#fff";
octx.fillRect(0, 0, 0, 0);
octx.clearRect(0, 0, w, h);
octx.fillText(cfg.current.text, w / 2, h / 2);
const data = octx.getImageData(0, 0, w, h).data;
const g = Math.max(2, Math.round(cfg.current.gap));
const homes: number[] = [];
for (let py = 0; py < h; py += g) {
for (let px = 0; px < w; px += g) {
if (data[(py * w + px) * 4 + 3]! > 128) homes.push(px, py);
}
}
count = Math.min(homes.length / 2, 9000);
const keep = homes.length / 2 <= 9000 ? 1 : (homes.length / 2) / 9000;
hx = new Float32Array(count);
hy = new Float32Array(count);
x = new Float32Array(count);
y = new Float32Array(count);
vx = new Float32Array(count);
vy = new Float32Array(count);
for (let i = 0; i < count; i++) {
const j = Math.floor(i * keep) * 2;
hx[i] = homes[j]!;
hy[i] = homes[j + 1]!;
x[i] = hx[i]!;
y[i] = hy[i]!;
}
seededText = cfg.current.text;
drawFrame();
};
const drawFrame = () => {
const c = cfg.current;
const w = el.clientWidth;
const h = el.clientHeight;
ctx.clearRect(0, 0, w, h);
for (let i = 0; i < count; i++) {
const sp = Math.min(1, Math.hypot(vx[i]!, vy[i]!) / 900);
// быстрые частицы светлеют и уходят в акцент — движение видно и по цвету
ctx.fillStyle = `oklch(${0.72 + sp * 0.2} ${0.11 + sp * 0.12} ${c.hue})`;
ctx.fillRect(x[i]! - 1, y[i]! - 1, 2.1, 2.1);
}
};
let mx = -1e5;
let my = -1e5;
let inside = false;
let attract = false;
let raf = 0;
let last = 0;
const step = (now: number) => {
const c = cfg.current;
const dt = Math.min(0.033, last ? (now - last) / 1000 : 0.016);
last = now;
const damp = Math.exp(-4.6 * dt);
const R = Math.max(20, c.radius);
let energy = 0;
for (let i = 0; i < count; i++) {
let ax = (hx[i]! - x[i]!) * c.spring;
let ay = (hy[i]! - y[i]!) * c.spring;
const dx = x[i]! - mx;
const dy = y[i]! - my;
const d = Math.hypot(dx, dy);
if (d < R && d > 0.5) {
const t = 1 - d / R;
// наведение расталкивает, зажатая кнопка ПРИТЯГИВАЕТ (и чуть сильнее:
// магниту положено держать крепче, чем отталкивать)
const f = (c.force * t * t) / d;
const dir = attract ? -1.35 : 1;
ax += dx * f * dir;
ay += dy * f * dir;
}
vx[i] = (vx[i]! + ax * dt) * damp;
vy[i] = (vy[i]! + ay * dt) * damp;
x[i] = x[i]! + vx[i]! * dt;
y[i] = y[i]! + vy[i]! * dt;
energy += Math.abs(vx[i]!) + Math.abs(vy[i]!);
}
drawFrame();
// Мера покоя — СКОРОСТИ, не смещения: частицы, удержанные полем неподвижного
// курсора, — статичная картинка, и жечь на неё кадры нечестно (хост занимает
// весь кадр, «увода курсора» можно ждать вечно). Следующий pointermove разбудит.
if (energy / Math.max(1, count) > 2) {
raf = requestAnimationFrame(step);
} else {
raf = 0;
if (!inside) {
// курсор ушёл: прибить хвосты дрожи, дорисовать всех ровно по домам
for (let i = 0; i < count; i++) {
x[i] = hx[i]!;
y[i] = hy[i]!;
vx[i] = 0;
vy[i] = 0;
}
drawFrame();
}
}
};
const wake = () => {
if (raf || frozen) return;
last = 0;
raf = requestAnimationFrame(step);
};
const onMove = (e: PointerEvent) => {
const r = el.getBoundingClientRect();
mx = e.clientX - r.left;
my = e.clientY - r.top;
inside = true;
wake();
};
const onLeave = () => {
inside = false;
attract = false;
mx = -1e5;
my = -1e5;
};
const onDown = (e: PointerEvent) => {
attract = true;
onMove(e);
};
const onUp = () => {
attract = false;
};
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerleave", onLeave);
el.addEventListener("pointerdown", onDown);
el.addEventListener("pointerup", onUp);
el.addEventListener("pointercancel", onUp);
build();
const ro = new ResizeObserver(build);
ro.observe(el);
const onVis = () => {
if (document.hidden) {
cancelAnimationFrame(raf);
raf = 0;
}
};
document.addEventListener("visibilitychange", onVis);
// смена текста пропом: пересобрать дома (размер кадра тот же)
const id = setInterval(() => {
if (cfg.current.text !== seededText) build();
}, 300);
return () => {
clearInterval(id);
cancelAnimationFrame(raf);
ro.disconnect();
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerleave", onLeave);
el.removeEventListener("pointerdown", onDown);
el.removeEventListener("pointerup", onUp);
el.removeEventListener("pointercancel", onUp);
document.removeEventListener("visibilitychange", onVis);
canvas.remove();
};
}, []);
return (
<div
ref={host}
className="mgt"
style={{
position: "absolute",
inset: 0,
touchAction: "none",
cursor: "crosshair",
background: "var(--ds-bg, oklch(0.13 0.02 275))",
}}
/>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); парная запись к text-particles — там хореография на GPU без курсора, здесь честная физика поля курсора на CPUv1.0.0