Per-letter font-weight swells toward the pointer or rolls through on a sine wave, animated frame-by-frame.
npx shadcn@latest add @paragon/variable-weight-text"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface VariableWeightTextProps
extends Omit<React.ComponentProps<"span">, "children"> {
/** The string to render, one wave-driven letter at a time. */
children: string;
/** Lightest weight a letter reaches at rest / far from the pointer. */
minWeight?: number;
/** Heaviest weight a letter reaches at the wave crest / under the pointer. */
maxWeight?: number;
/** Pointer influence radius in px (only used in `pointer` mode). */
radius?: number;
/**
* `pointer` — weight swells toward the cursor.
* `wave` — a sine crest travels through the letters on a loop.
*/
mode?: "pointer" | "wave";
/** Wave speed multiplier (only used in `wave` mode). */
speed?: number;
/** Also modulate font stretch (font-stretch) for extra depth, if available. */
animateWidth?: boolean;
/** Render the final text at min weight with no motion. */
static?: boolean;
}
/**
* VariableWeightText — per-letter font-weight animates on a smooth rAF loop.
*
* In `pointer` mode each glyph's weight swells toward the cursor and eases back
* out with distance, so the word thickens under the pointer like it's being
* pressed. In `wave` mode a sine crest of weight travels through the letters
* forever. Buttery because weights are written straight to the DOM per frame
* (no React re-render), interpolated with a critically-damped ease.
*
* Engine craft: letter centers are measured once (and re-measured on resize
* and after webfonts finish loading) relative to the host, so a frame costs a
* single `getBoundingClientRect` — never one per letter. Weights are quantized
* to integers and only written when they change. In pointer mode the loop
* sleeps entirely once every letter has settled and wakes on the next pointer
* event; the wave keeps its phase across offscreen pauses so the loop never
* skips. Pointer physics needs a real hover pointer — on coarse/touch inputs
* `pointer` mode falls back to the ambient wave instead of reading as dead.
*
* Uses a variable font axis (`font-variation-settings: "wght"`), falling back to
* `font-weight` where the axis isn't present. Real text stays in the DOM for
* screen readers; reduced-motion (or `static`) paints the resting weight.
*/
export function VariableWeightText({
children,
minWeight = 300,
maxWeight = 800,
radius = 120,
mode = "pointer",
speed = 1,
animateWidth = false,
static: isStatic = false,
className,
style,
...props
}: VariableWeightTextProps) {
const hostRef = React.useRef<HTMLSpanElement>(null);
const letterRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const reducedMotion = useReducedMotion() ?? false;
const chars = React.useMemo(() => Array.from(children), [children]);
React.useEffect(() => {
if (isStatic || reducedMotion) return;
const host = hostRef.current;
if (!host) return;
// Hover physics only makes sense with a fine pointer; on touch devices
// the crest degrades to the ambient wave so the headline stays alive.
const finePointer =
typeof window.matchMedia === "function" &&
window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const activeMode = mode === "pointer" && !finePointer ? "wave" : mode;
let raf = 0;
let inView = true;
let disposed = false;
// Wave clock accumulates only while animating, so offscreen pauses and
// hidden tabs resume mid-phase instead of jumping.
let elapsed = 0;
let last: number | null = null;
const cur = new Float32Array(chars.length);
const applied = new Int16Array(chars.length).fill(-1);
const pointer = { x: 0, y: 0, active: false };
// Letter centers cached relative to the host box: one layout read per
// frame (the host rect) instead of one per letter.
const centers = new Float32Array(chars.length * 2);
const measure = () => {
if (disposed) return;
const hostRect = host.getBoundingClientRect();
for (let i = 0; i < chars.length; i++) {
const el = letterRefs.current[i];
if (!el) continue;
const r = el.getBoundingClientRect();
centers[i * 2] = r.left + r.width / 2 - hostRect.left;
centers[i * 2 + 1] = r.top + r.height / 2 - hostRect.top;
}
};
measure();
const resizeObserver =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(measure)
: null;
resizeObserver?.observe(host);
// Webfont swaps shift glyph centers after first paint.
if (typeof document !== "undefined" && "fonts" in document) {
document.fonts.ready.then(measure).catch(() => {});
}
const write = (i: number, w01: number) => {
const el = letterRefs.current[i];
if (!el) return;
const wght = Math.round(minWeight + (maxWeight - minWeight) * w01);
if (applied[i] === wght) return;
applied[i] = wght;
el.style.fontWeight = String(wght);
el.style.fontVariationSettings = animateWidth
? `"wght" ${wght}, "wdth" ${Math.round(85 + 30 * w01)}`
: `"wght" ${wght}`;
};
const tick = (now: number) => {
raf = 0;
if (!inView || disposed) return;
if (last !== null) elapsed += Math.min(now - last, 100);
last = now;
const t = elapsed / 1000;
let hostRect: DOMRect | null = null;
if (activeMode === "pointer" && pointer.active) {
hostRect = host.getBoundingClientRect();
}
let settled = activeMode === "pointer";
for (let i = 0; i < chars.length; i++) {
let target = 0;
if (activeMode === "wave") {
const phase = t * speed * 2.2 - i * 0.5;
target = (Math.sin(phase) + 1) / 2;
} else if (hostRect) {
const dx = pointer.x - (hostRect.left + centers[i * 2]);
const dy = pointer.y - (hostRect.top + centers[i * 2 + 1]);
const falloff = Math.max(0, 1 - Math.hypot(dx, dy) / radius);
// Smooth the falloff so the crest reads as a soft bell.
target = falloff * falloff * (3 - 2 * falloff);
}
// Critically-damped ease toward the target — no overshoot.
cur[i] += (target - cur[i]) * 0.18;
if (Math.abs(target - cur[i]) > 0.001) settled = false;
else cur[i] = target;
write(i, cur[i]);
}
// Pointer mode sleeps once fully settled; the next pointer event (or
// re-entering the viewport) wakes it. The wave never settles.
if (settled) {
last = null;
return;
}
raf = requestAnimationFrame(tick);
};
const wake = () => {
if (!raf && inView && !disposed) raf = requestAnimationFrame(tick);
};
const io = new IntersectionObserver(
([entry]) => {
inView = entry?.isIntersecting ?? true;
if (inView) wake();
else last = null;
},
{ threshold: 0 },
);
io.observe(host);
const onMove = (e: PointerEvent) => {
pointer.x = e.clientX;
pointer.y = e.clientY;
pointer.active = true;
wake();
};
const onLeave = () => {
pointer.active = false;
wake();
};
if (activeMode === "pointer") {
host.addEventListener("pointermove", onMove);
host.addEventListener("pointerleave", onLeave);
}
wake();
return () => {
disposed = true;
io.disconnect();
resizeObserver?.disconnect();
if (activeMode === "pointer") {
host.removeEventListener("pointermove", onMove);
host.removeEventListener("pointerleave", onLeave);
}
if (raf) cancelAnimationFrame(raf);
};
}, [
chars,
minWeight,
maxWeight,
radius,
mode,
speed,
animateWidth,
isStatic,
reducedMotion,
]);
const rest = isStatic || reducedMotion;
return (
<span
ref={hostRef}
data-slot="variable-weight-text"
className={cn("inline-block", className)}
style={{ fontWeight: minWeight, ...style }}
{...props}
>
<span className="sr-only">{children}</span>
<span aria-hidden="true">
{chars.map((ch, i) =>
/\s/.test(ch) ? (
<React.Fragment key={i}>{ch}</React.Fragment>
) : (
<span
key={i}
ref={(el) => {
letterRefs.current[i] = el;
}}
className="inline-block"
style={
rest
? {
fontWeight: minWeight,
fontVariationSettings: `"wght" ${minWeight}`,
}
: { willChange: "font-variation-settings, font-weight" }
}
>
{ch}
</span>
),
)}
</span>
</span>
);
}