Variable Font Hover
Вес букв тянется к курсору: чем ближе буква, тем жирнее (гаусс по расстоянию, font-variation-settings).
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| text? | string | TEXT |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/variable-font-hover.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.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>
);
}