Letters spring away from the cursor and recoil on individual springs, rippling as you sweep across them.
npx shadcn@latest add @paragon/elastic-text"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>
);
}