Skip to content

Weight Wave Text

Per-character variable font weight that swells around the pointer and drifts when idle.

Freeeditorialminimalplayful
Category
Text Effects
Added
2026-09-20
Deps
none
Updated
2026-09-20
Preview
ConsideredSweep across

Props

200
900
110
No runtime dependencies
"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>
  );
}
Notes

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.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

Featured
New

Slice Shear Hover

text effects

Display type cut into horizontal bands that fan apart under the pointer.

hovertextclip-path
Featured
NewPro

Particles sampled from the glyphs themselves, assembling into a word and scattering on hover.

textparticlescanvas
Trending
New

Extruded Type Tilt

text effects

Display type with a solid extrusion that swings around as the pointer moves.

text3dextrude