Text Effects

Word Stagger

A heading whose words cascade in one after another with the house rise enter (opacity, translateY, blur) on mount or in view.

Install

npx shadcn@latest add @paragon/word-stagger

word-stagger.tsx

"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>
  );
}