A heading whose words cascade in one after another with the house rise enter (opacity, translateY, blur) on mount or in view.
npx shadcn@latest add @paragon/word-stagger"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
export interface WordStaggerProps
extends Omit<React.ComponentProps<"span">, "children"> {
/** The heading text. Words are split on whitespace. */
children: string;
/** When the cascade starts: on mount (demo replay) or on scroll into view. */
trigger?: "mount" | "view";
/** Milliseconds before the first word enters. */
delay?: number;
/** Milliseconds between words. House range is 50–80ms. */
stagger?: number;
/** Render the text plainly with no motion. */
static?: boolean;
}
/**
* A heading whose words cascade in one after another with the house rise
* enter — opacity 0→1, translateY(12px)→0, blur(4px)→0 on ease-out — with a
* 50–80ms stagger between words.
*
* Inline element: wrap it in your own heading tag. The full string stays
* available to screen readers; the animated words are decorative. Runs on
* mount by default so replays re-fire; set trigger="view" to wait for
* scroll. Reduced motion fades the whole heading in at once — no movement,
* no blur, no stagger.
*/
export function WordStagger({
children,
trigger = "mount",
delay = 0,
stagger = 60,
static: isStatic = false,
className,
style,
...props
}: WordStaggerProps) {
const ref = React.useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, amount: 0.4 });
const reducedMotion = useReducedMotion() ?? false;
// Mount trigger: flip after first paint so the hidden state is committed
// and the CSS transitions have something to run from.
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
const raf = requestAnimationFrame(() => setMounted(true));
return () => cancelAnimationFrame(raf);
}, []);
const words = React.useMemo(
() => children.split(/\s+/).filter(Boolean),
[children],
);
if (isStatic) {
return (
<span className={className} style={style} {...props}>
{children}
</span>
);
}
const shown = trigger === "view" ? inView : mounted;
return (
<span
ref={ref}
className={className}
style={
// Reduced motion: one plain fade for the whole heading.
reducedMotion
? {
opacity: shown ? 1 : 0,
transitionProperty: "opacity",
transitionDuration: "var(--duration-base)",
transitionTimingFunction: "var(--ease-out)",
transitionDelay: shown ? `${delay}ms` : "0ms",
...style,
}
: style
}
{...props}
>
<span className="sr-only">{children}</span>
<span aria-hidden="true">
{words.map((word, i) => (
<React.Fragment key={i}>
<span
className="inline-block"
style={
reducedMotion
? undefined
: {
opacity: shown ? 1 : 0,
transform: shown
? "translateY(0px)"
: "translateY(12px)",
filter: shown ? "blur(0px)" : "blur(4px)",
transitionProperty: "opacity, transform, filter",
transitionDuration: "var(--duration-base)",
transitionTimingFunction: "var(--ease-out)",
transitionDelay: shown
? `${delay + i * stagger}ms`
: "0ms",
}
}
>
{word}
</span>
{i < words.length - 1 ? " " : null}
</React.Fragment>
))}
</span>
</span>
);
}