Text Effects

Elastic Text

Letters spring away from the cursor and recoil on individual springs, rippling as you sweep across them.

Install

npx shadcn@latest add @paragon/elastic-text

elastic-text.tsx

"use client";

import * as React from "react";
import {
  motion,
  useMotionValue,
  useMotionTemplate,
  useSpring,
  useReducedMotion,
  type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";

const SPRING: SpringOptions = { stiffness: 260, damping: 14, mass: 0.9 };

export interface ElasticTextProps
  extends Omit<React.ComponentProps<"span">, "children"> {
  /** The string to render, letter by letter. */
  children: string;
  /** How far letters shove away from the cursor, in px at closest range. */
  strength?: number;
  /** Pointer influence radius in px. */
  radius?: number;
  /** Optional accent color applied to letters as they're displaced. */
  color?: string;
  /** Render the final text with no motion. */
  static?: boolean;
}

interface LetterProps {
  ch: string;
  pointerX: ReturnType<typeof useMotionValue<number>>;
  pointerY: ReturnType<typeof useMotionValue<number>>;
  active: ReturnType<typeof useMotionValue<number>>;
  strength: number;
  radius: number;
  color?: string;
}

function ElasticLetter({
  ch,
  pointerX,
  pointerY,
  active,
  strength,
  radius,
  color,
}: LetterProps) {
  const ref = React.useRef<HTMLSpanElement>(null);
  const dx = useMotionValue(0);
  const dy = useMotionValue(0);
  const sc = useMotionValue(1);
  const x = useSpring(dx, SPRING);
  const y = useSpring(dy, SPRING);
  const scale = useSpring(sc, SPRING);
  const tint = useMotionValue(0);
  const tintS = useSpring(tint, { stiffness: 200, damping: 26 });
  // Fade the accent color in over the base text color as the letter displaces.
  const colorMix = useMotionTemplate`color-mix(in oklab, ${
    color ?? "currentColor"
  } calc(${tintS} * 100%), currentColor)`;

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const compute = () => {
      if (active.get() < 0.5) {
        dx.set(0);
        dy.set(0);
        sc.set(1);
        tint.set(0);
        return;
      }
      const r = el.getBoundingClientRect();
      const cx = r.left + r.width / 2;
      const cy = r.top + r.height / 2;
      const vx = cx - pointerX.get();
      const vy = cy - pointerY.get();
      const d = Math.hypot(vx, vy) || 0.0001;
      const falloff = Math.max(0, 1 - d / radius);
      const smooth = falloff * falloff * (3 - 2 * falloff);
      dx.set((vx / d) * strength * smooth);
      dy.set((vy / d) * strength * smooth);
      sc.set(1 + 0.35 * smooth);
      tint.set(smooth);
    };
    const unsubs = [
      pointerX.on("change", compute),
      pointerY.on("change", compute),
      active.on("change", compute),
    ];
    return () => unsubs.forEach((u) => u());
  }, [pointerX, pointerY, active, dx, dy, sc, tint, strength, radius]);

  return (
    <motion.span
      ref={ref}
      className="inline-block"
      style={{
        x,
        y,
        scale,
        color: color ? colorMix : undefined,
        willChange: "transform",
      }}
    >
      {ch}
    </motion.span>
  );
}

/**
 * ElasticText — letters spring away from the cursor and settle back with a
 * springy overshoot. Each glyph runs its own critically-underdamped
 * `useSpring`, pushed along the vector from the pointer, so the word ripples
 * and recoils as you sweep across it — like poking a taut sheet of letters.
 *
 * Real text stays in the DOM for screen readers; motion is decorative. On
 * coarse pointers (touch) and under reduced-motion, the text renders static.
 */
export function ElasticText({
  children,
  strength = 26,
  radius = 110,
  color,
  static: isStatic = false,
  className,
  ...props
}: ElasticTextProps) {
  const hostRef = React.useRef<HTMLSpanElement>(null);
  const reducedMotion = useReducedMotion() ?? false;
  const [fine, setFine] = React.useState(false);

  const pointerX = useMotionValue(-9999);
  const pointerY = useMotionValue(-9999);
  const active = useMotionValue(0);

  const chars = React.useMemo(() => Array.from(children), [children]);

  React.useEffect(() => {
    if (typeof window === "undefined" || !window.matchMedia) return;
    const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
    const sync = () => setFine(mql.matches);
    sync();
    mql.addEventListener("change", sync);
    return () => mql.removeEventListener("change", sync);
  }, []);

  React.useEffect(() => {
    const host = hostRef.current;
    if (!host || isStatic || reducedMotion || !fine) return;
    const onMove = (e: PointerEvent) => {
      pointerX.set(e.clientX);
      pointerY.set(e.clientY);
      active.set(1);
    };
    const onLeave = () => active.set(0);
    host.addEventListener("pointermove", onMove);
    host.addEventListener("pointerleave", onLeave);
    return () => {
      host.removeEventListener("pointermove", onMove);
      host.removeEventListener("pointerleave", onLeave);
    };
  }, [isStatic, reducedMotion, fine, pointerX, pointerY, active]);

  if (isStatic || reducedMotion || !fine) {
    return (
      <span
        ref={hostRef}
        data-slot="elastic-text"
        className={cn("inline-block", className)}
        {...props}
      >
        {children}
      </span>
    );
  }

  return (
    <span
      ref={hostRef}
      data-slot="elastic-text"
      className={cn("inline-block", className)}
      {...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>
          ) : (
            <ElasticLetter
              key={i}
              ch={ch}
              pointerX={pointerX}
              pointerY={pointerY}
              active={active}
              strength={strength}
              radius={radius}
              color={color}
            />
          ),
        )}
      </span>
    </span>
  );
}