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.
 *
 * 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>
  );
}