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.
*
* 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;
let raf = 0;
let running = true;
// current + target weight (0..1 normalized) per letter, so we can ease.
const cur = new Float32Array(chars.length);
const pointer = { x: -9999, y: -9999, active: mode !== "pointer" };
let start: number | null = null;
const io = new IntersectionObserver(
([entry]) => {
running = entry.isIntersecting;
if (running && !raf) raf = requestAnimationFrame(tick);
},
{ threshold: 0 },
);
io.observe(host);
const onMove = (e: PointerEvent) => {
pointer.x = e.clientX;
pointer.y = e.clientY;
pointer.active = true;
};
const onLeave = () => {
pointer.active = false;
};
if (mode === "pointer") {
host.addEventListener("pointermove", onMove);
host.addEventListener("pointerleave", onLeave);
}
const applyWeight = (el: HTMLSpanElement, w01: number) => {
const wght = Math.round(minWeight + (maxWeight - minWeight) * w01);
el.style.fontWeight = String(wght);
el.style.fontVariationSettings = animateWidth
? `"wght" ${wght}, "wdth" ${Math.round(85 + 30 * w01)}`
: `"wght" ${wght}`;
};
const tick = (now: number) => {
if (!running) {
raf = 0;
return;
}
if (start === null) start = now;
const t = (now - start) / 1000;
for (let i = 0; i < chars.length; i++) {
const el = letterRefs.current[i];
if (!el) continue;
let target: number;
if (mode === "wave") {
// Sine crest travelling through the letters.
const phase = t * speed * 2.2 - i * 0.5;
target = (Math.sin(phase) + 1) / 2;
} else {
if (!pointer.active) {
target = 0;
} else {
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const d = Math.hypot(pointer.x - cx, pointer.y - cy);
const falloff = Math.max(0, 1 - d / radius);
// Smooth the falloff so the crest reads as a soft bell.
target = falloff * falloff * (3 - 2 * falloff);
}
}
// Critically-damped ease toward target.
cur[i] += (target - cur[i]) * 0.18;
applyWeight(el, cur[i]);
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
io.disconnect();
if (mode === "pointer") {
host.removeEventListener("pointermove", onMove);
host.removeEventListener("pointerleave", onLeave);
}
cancelAnimationFrame(raf);
};
}, [
chars.length,
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>
);
}