Variable Weight Text
Text Effects

Variable Weight Text

Per-letter font-weight swells toward the pointer or rolls through on a sine wave, animated frame-by-frame.

Install

npx shadcn@latest add @paragon/variable-weight-text

variable-weight-text.tsx

"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>
  );
}