← каталог
Variable Font Hover
Вес букв тянется к курсору: чем ближе буква, тем жирнее (гаусс по расстоянию, font-variation-settings).
cssmouse-movehovermagneticintermediate0reduced-motion ✓
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| text? | string | TEXT |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/variable-font-hover.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";
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