Weight Wave Text
Per-character variable font weight that swells around the pointer and drifts when idle.
- Category
- Text Effects
- Added
- 2026-09-20
- Deps
- none
- Updated
- 2026-09-20
Props
"use client";
import { useEffect, useRef } from "react";
export function WeightWaveText({
text,
minWeight = 200,
maxWeight = 900,
reach = 110,
}: {
text: string;
minWeight?: number;
maxWeight?: number;
reach?: number;
}) {
const hostRef = useRef<HTMLSpanElement>(null);
const charRefs = useRef<(HTMLSpanElement | null)[]>([]);
const pointer = useRef<number | null>(null);
const frame = useRef<number | null>(null);
useEffect(() => {
let t = 0;
let last = 0;
function loop() {
const now = performance.now();
const dt = last ? Math.min(100, now - last) : 16.7;
last = now;
//
// TIME-BASED, NOT PER-FRAME.
//
// Advancing a fixed amount every frame ties the motion to the refresh
// rate. The identical code runs at DOUBLE speed on a 120Hz panel and
// 2.4x on a 144Hz one — and you never see it on the machine you tuned
// it on, which is why a background that felt calm in development
// arrives frantic on someone else's monitor. k is the frame's length
// measured against 60Hz, clamped so a backgrounded tab cannot return
// with a 4-second delta and teleport everything at once.
// 0.02 per frame at 60Hz is 1.2 per second.
t += (dt / 1000) * 1.2;
const host = hostRef.current?.getBoundingClientRect();
charRefs.current.forEach((el, i) => {
if (!el || !host) return;
const rect = el.getBoundingClientRect();
const center = rect.left + rect.width / 2 - host.left;
let intensity: number;
if (pointer.current === null) {
// Idle: a slow sine wave travelling the line, so the headline is
// alive before anyone interacts with it.
intensity = ((Math.sin(t - i * 0.45) + 1) / 2) * 0.55;
} else {
// Squared falloff so the swell has a soft shoulder.
const d = Math.abs(center - pointer.current);
intensity = Math.pow(Math.max(0, 1 - d / reach), 2);
}
const weight = Math.round(minWeight + (maxWeight - minWeight) * intensity);
// Written straight to the element. A setState per frame would
// re-render the whole headline ~60 times a second.
el.style.fontVariationSettings = '"wght" ' + weight;
});
frame.current = requestAnimationFrame(loop);
}
frame.current = requestAnimationFrame(loop);
return () => {
if (frame.current !== null) cancelAnimationFrame(frame.current);
};
}, [minWeight, maxWeight, reach]);
return (
<span
ref={hostRef}
onMouseMove={(e) => {
const rect = hostRef.current?.getBoundingClientRect();
if (rect) pointer.current = e.clientX - rect.left;
}}
onMouseLeave={() => {
pointer.current = null;
}}
className="cursor-default select-none text-5xl leading-none tracking-tight"
>
{/* One accessible copy; the per-character spans are decorative. */}
<span className="sr-only">{text}</span>
<span aria-hidden>
{text.split("").map((ch, i) => (
<span
key={i}
ref={(el) => {
charRefs.current[i] = el;
}}
className="inline-block"
style={{ fontVariationSettings: '"wght" ' + minWeight }}
>
{ch === " " ? "\u00A0" : ch}
</span>
))}
</span>
</span>
);
}
About this text effect
Variable fonts expose a continuous weight axis, and animating it is one of the few kinetic type effects that never harms legibility — the letters do not move, blur, or scramble, they just gain and lose mass. That makes this usable on a real headline rather than only on decorative type. Two behaviours are combined: a squared falloff around the pointer, which gives the swell a soft shoulder instead of a visible cutoff, and a slow travelling sine wave when the pointer is absent, so the headline is alive before anyone interacts with it. The performance requirement is absolute. Weight is written straight to each span from a requestAnimationFrame loop through refs; routing it through React state would re-render the entire headline sixty times a second for an effect that is pure style. The hard prerequisite is a genuine variable font — with a static family carrying two weights the browser will step between them and the result looks broken rather than smooth. Splitting text per character also costs selection and can cause letter-by-letter announcement, so one hidden copy of the real string carries the content.
Curator’s note
Built for viberdy 2.0. The idle sine wave matters as much as the hover response — without it the headline is inert until someone happens to mouse over it.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Slice Shear Hover
text effectsDisplay type cut into horizontal bands that fan apart under the pointer.
Particle Assemble Text
text effectsParticles sampled from the glyphs themselves, assembling into a word and scattering on hover.
Extruded Type Tilt
text effectsDisplay type with a solid extrusion that swings around as the pointer moves.