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

Magnet Text

Надпись из частиц с НАСТОЯЩЕЙ физикой курсора: наведение расталкивает, зажатая кнопка притягивает, пружина возвращает буквы домой. Canvas 2D, все состояния в типизированных массивах, цикл засыпает в покое.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
text?string"МАГНИТ"Надпись (формат «МАГНИТ»).
radius?number110Радиус поля курсора, px (формат «110»).
force?number2600Сила расталкивания, px/с² на единицу близости (формат «2600»).
spring?number46Жёсткость пружины возврата домой (формат «46»).
gap?number4Шаг сетки частиц, px: меньше — плотнее и дороже (формат «4»).
hue?number300Тон в oklch, 0–360 (формат «300»).

Установка

npx shadcn@latest add https://motiva.pages.dev/r/magnet-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 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