"use client";
import * as React from "react";
import { animate, type AnimationPlaybackControls } from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
export type WaveTextTrigger = "hover" | "view" | "continuous";
export interface WaveTextProps extends Omit<React.HTMLAttributes<HTMLElement>, "children" | "color"> {
/** The text to render. */
text?: string;
/** Element to render. */
as?: "span" | "p" | "h1" | "h2" | "h3" | "div";
/**
* `hover`: on pointer enter (from the nearest letter), tap, or focus of the closest focusable
* ancestor (from the start). `view`: when scrolled into view. `continuous`: keeps rippling while visible.
*/
trigger?: WaveTextTrigger;
/** Repeat while hovered (hover) or while visible (view). */
loop?: boolean;
/** Pause between repeated waves, in ms. */
loopDelay?: number;
/** Peak lift of each letter, in px. */
amplitude?: number;
/** Delay between neighbouring letters, in ms. */
stagger?: number;
/** Spring each letter rides; its damping decides how much it bounces. */
spring?: Spring;
/** Peak scale of a letter (1 = no scaling). */
scale?: number;
/** Peak rotation of a letter in degrees (0 = upright). */
rotate?: number;
/** Colour letters flash as the wave passes. `null` disables the colour flash. */
color?: string | null;
}
/* --------------------------------- helpers --------------------------------- */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/** Splits text into words (kept unbreakable) and whitespace, and words into graphemes. */
function tokenize(text: string) {
const segmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null;
const graphemes = (word: string) => (segmenter ? Array.from(segmenter.segment(word), (s) => s.segment) : Array.from(word));
let index = 0;
return text.split(/(\s+)/).map((part) =>
/^\s+$/.test(part) || part === ""
? { space: part, letters: [] as { char: string; index: number }[] }
: { space: "", letters: graphemes(part).map((char) => ({ char, index: index++ })) },
);
}
/**
* Initial velocity that makes a spring starting at rest position overshoot to exactly `peak`.
* For an underdamped spring the first peak of x(t) = (v₀/ω_d)·e^(−ζω₀t)·sin(ω_d t) is
* (v₀/ω₀)·e^(−ζ/√(1−ζ²)·atan(√(1−ζ²)/ζ)), so we invert that.
*/
function kickVelocity(peak: number, { stiffness, damping, mass }: Spring) {
if (peak === 0) return 0;
const w0 = Math.sqrt(stiffness / mass);
const zeta = damping / (2 * Math.sqrt(stiffness * mass));
if (zeta >= 1) return peak * w0 * Math.E;
const s = Math.sqrt(1 - zeta * zeta);
return (peak * w0) / Math.exp((-zeta / s) * Math.atan2(s, zeta));
}
const COLOR_MIX = "color-mix(in oklab, var(--wt-color) calc(var(--wt-mix, 0) * 100%), currentColor)";
/**
* Letters that ripple in a wave: each one gets a spring "kick" so it lifts by `amplitude` and settles,
* with a stagger spreading outward from the letter nearest the pointer (or from the first letter).
* The text is exposed once to assistive tech; the animated letters are aria-hidden.
*/
export function WaveText({
text = "Making waves",
as = "span",
trigger = "hover",
loop = false,
loopDelay = 1200,
amplitude = 10,
stagger = 40,
spring = { stiffness: 320, damping: 11, mass: 0.6 },
scale = 1,
rotate = 0,
color = "#FF7448",
className = "",
style,
...props
}: WaveTextProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLElement>(null);
const visualRef = React.useRef<HTMLSpanElement>(null);
const Tag = as as React.ElementType;
const tokens = tokenize(text);
const { stiffness, damping, mass } = spring;
React.useEffect(() => {
const root = rootRef.current;
const visual = visualRef.current;
if (!root || !visual || reduceMotion) return;
const letters = Array.from(visual.querySelectorAll<HTMLElement>("[data-wt-letter]"));
if (!letters.length) return;
const physics = { stiffness, damping, mass };
const running = new Set<AnimationPlaybackControls>();
const track = (controls: AnimationPlaybackControls) => {
running.add(controls);
void controls.finished.then(() => running.delete(controls)).catch(() => running.delete(controls));
};
const wave = (origin: number) => {
letters.forEach((el, i) => {
const delay = (Math.abs(i - origin) * stagger) / 1000;
const spec = { type: "spring" as const, ...physics, delay };
// Rest → rest with an initial velocity: a single, physically honest bump.
track(
animate(
el,
{ y: 0, scale: 1, rotate: 0 },
{
y: { ...spec, velocity: -kickVelocity(amplitude, physics) },
scale: { ...spec, velocity: kickVelocity(scale - 1, physics) },
rotate: { ...spec, velocity: kickVelocity(rotate, physics) },
},
),
);
if (color) track(animate(el, { "--wt-mix": [1, 0] }, { duration: 0.75, delay, ease: [0.22, 1, 0.36, 1] }));
});
};
const nearest = (x: number, y: number) => {
let best = 0;
let bestDistance = Infinity;
letters.forEach((el, i) => {
const r = el.getBoundingClientRect();
const d = Math.hypot(r.left + r.width / 2 - x, r.top + r.height / 2 - y);
if (d < bestDistance) {
bestDistance = d;
best = i;
}
});
return best;
};
// Repeating waves (loop / continuous). Skipped while the tab is hidden.
const period = Math.max(loopDelay, 0) + letters.length * stagger + 500;
let timer: ReturnType<typeof setInterval> | undefined;
const startLoop = (origin: () => number) => {
clearInterval(timer);
timer = setInterval(() => {
if (!document.hidden) wave(origin());
}, period);
};
const stopLoop = () => clearInterval(timer);
const cleanups: (() => void)[] = [];
if (trigger === "hover") {
// Hover the closest interactive ancestor too (e.g. a button or link wrapping the text).
const host = root.closest<HTMLElement>("a, button, [tabindex], label") ?? root;
let pointer = { x: 0, y: 0 };
const onEnter = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return;
pointer = { x: event.clientX, y: event.clientY };
wave(nearest(pointer.x, pointer.y));
if (loop) startLoop(() => nearest(pointer.x, pointer.y));
};
const onMove = (event: PointerEvent) => {
pointer = { x: event.clientX, y: event.clientY };
};
const onDown = (event: PointerEvent) => {
if (event.pointerType !== "mouse") wave(nearest(event.clientX, event.clientY));
};
const onFocus = () => wave(0);
host.addEventListener("pointerenter", onEnter);
host.addEventListener("pointermove", onMove);
host.addEventListener("pointerleave", stopLoop);
host.addEventListener("pointerdown", onDown);
host.addEventListener("focusin", onFocus);
cleanups.push(() => {
host.removeEventListener("pointerenter", onEnter);
host.removeEventListener("pointermove", onMove);
host.removeEventListener("pointerleave", stopLoop);
host.removeEventListener("pointerdown", onDown);
host.removeEventListener("focusin", onFocus);
});
} else {
const repeat = trigger === "continuous" || loop;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
wave(0);
if (repeat) startLoop(() => 0);
else observer.disconnect();
} else {
stopLoop();
}
},
{ threshold: 0.5 },
);
observer.observe(root);
cleanups.push(() => observer.disconnect());
}
return () => {
stopLoop();
cleanups.forEach((fn) => fn());
running.forEach((controls) => controls.stop());
// Leave letters at rest if we were interrupted mid-wave.
letters.forEach((el) => {
el.style.transform = "";
el.style.removeProperty("--wt-mix");
});
};
}, [text, trigger, loop, loopDelay, amplitude, stagger, stiffness, damping, mass, scale, rotate, color, reduceMotion]);
return (
<Tag
ref={rootRef}
className={className}
style={{ ...(color ? { ["--wt-color" as string]: color } : null), ...style }}
{...props}
>
<span className="sr-only">{text}</span>
<span ref={visualRef} aria-hidden="true">
{tokens.map((token, i) =>
token.letters.length === 0 ? (
token.space
) : (
// Words stay unbreakable; wrapping happens only at the original whitespace.
<span key={i} className="inline-block whitespace-nowrap">
{token.letters.map(({ char, index }) => (
<span key={index} data-wt-letter="" className="inline-block origin-bottom" style={color ? { color: COLOR_MIX } : undefined}>
{char}
</span>
))}
</span>
),
)}
</span>
</Tag>
);
}