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

Variable Font Hover

Вес букв тянется к курсору: чем ближе буква, тем жирнее (гаусс по расстоянию, font-variation-settings).

cssmouse-movehovermagneticintermediate0reduced-motion ✓

Пропсы

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

ПропТипПо умолчаниюЧто это
text?stringTEXT

Установка

npx shadcn@latest add https://motiva.pages.dev/r/variable-font-hover.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";

const TEXT = "ПЕРЕМЕННЫЙ ВЕС";

/**
 * Вес букв тянется к курсору (гаусс по расстоянию). Быстрая схема без layout-thrash:
 * центры букв кэшируются (не читаются в mousemove), события только пишут mouseX,
 * веса считает rAF-луп с lerp и пишет ТОЛЬКО изменившиеся (без CSS transition),
 * луп засыпает, когда веса доехали. Уважает reduced-motion / data-shot (равный вес).
 */
export function VariableFontHover({ text = TEXT }: { text?: string }) {
  const root = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = root.current;
    if (!el) return;

    const frozen =
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (frozen) return;

    const chars = [...el.querySelectorAll<HTMLElement>(".vf-ch")];
    const n = chars.length;
    const centers = new Float64Array(n);
    const current = new Float64Array(n).fill(400);
    const written = new Int16Array(n).fill(400);
    let mouseX: number | null = null;
    let raf = 0;
    let running = false;

    // центры читаем РЕДКО (на входе курсора/resize), а не в каждом mousemove —
    // микросдвиги от смены весов гауссу не важны, зато из горячего пути уходят все reflow
    const cacheCenters = () => {
      for (let i = 0; i < n; i++) {
        const r = chars[i]!.getBoundingClientRect();
        centers[i] = r.left + r.width / 2;
      }
    };

    const tick = () => {
      let busy = false;
      for (let i = 0; i < n; i++) {
        const target =
          mouseX === null ? 400 : 400 + 500 * Math.exp(-((mouseX - centers[i]!) ** 2) / (2 * 70 * 70));
        current[i]! += (target - current[i]!) * 0.28;
        if (Math.abs(target - current[i]!) > 0.7) busy = true;
        const w = Math.round(current[i]!);
        if (w !== written[i]) {
          written[i] = w;
          const ch = chars[i]!;
          ch.style.fontWeight = String(w);
          ch.style.fontVariationSettings = `"wght" ${w}`;
        }
      }
      if (!busy) {
        running = false; // всё доехало — спим до следующего события
        return;
      }
      raf = requestAnimationFrame(tick);
    };
    const wake = () => {
      if (!running) {
        running = true;
        raf = requestAnimationFrame(tick);
      }
    };

    const onEnter = () => cacheCenters();
    const onMove = (e: PointerEvent) => {
      mouseX = e.clientX; // только запись — никаких чтений layout здесь
      wake();
    };
    const onLeave = () => {
      mouseX = null;
      wake();
    };
    const onResize = () => cacheCenters();

    el.addEventListener("pointerenter", onEnter);
    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerleave", onLeave);
    window.addEventListener("resize", onResize);
    cacheCenters();

    return () => {
      cancelAnimationFrame(raf);
      el.removeEventListener("pointerenter", onEnter);
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerleave", onLeave);
      window.removeEventListener("resize", onResize);
    };
  }, [text]);

  return (
    <div
      ref={root}
      style={{
        cursor: "default",
        color: "oklch(0.92 0.01 265)",
        fontSize: "clamp(1.6rem, 1rem + 3.4vw, 3rem)",
        letterSpacing: "0.02em",
        userSelect: "none",
        whiteSpace: "nowrap",
        padding: "0.5em",
      }}
    >
      {text.split("").map((c, i) => (
        <span
          key={i}
          className="vf-ch"
          style={{ display: "inline-block", fontWeight: 400, fontVariationSettings: '"wght" 400' }}
        >
          {c === " " ? " " : c}
        </span>
      ))}
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — Codrops variable font demosv1.0.0