Text Effects

Text Reveal

Reveals text per word or line with the house rise enter (opacity, translateY, blur) staggered 50-80ms, triggered on scroll into view.

Install

npx shadcn@latest add @paragon/text-reveal

text-reveal.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface TextRevealProps
  extends Omit<React.ComponentProps<"span">, "children"> {
  /** The text to reveal. Newlines split into lines in both modes. */
  children: string;
  /** Reveal granularity: per word, or per line (split on newlines). */
  by?: "word" | "line";
  /** Milliseconds before the first segment enters. */
  delay?: number;
  /** Milliseconds between segments. House range is 50–80ms. */
  stagger?: number;
  /** Reveal once and stay visible, or re-hide when scrolled back out. */
  once?: boolean;
  /** Render the text plainly with no motion. */
  static?: boolean;
}

/**
 * Reveals text with the house rise enter — opacity 0→1, translateY(12px)→0,
 * blur(4px)→0 on ease-out — staggered per word or per line, triggered when
 * the element scrolls into view.
 *
 * Inline element: wrap it in your own heading or paragraph. The full string
 * stays available to screen readers; the animated segments are decorative.
 * Reduced motion collapses the effect to a plain fade.
 */
export function TextReveal({
  children,
  by = "word",
  delay = 0,
  stagger = 60,
  once = true,
  static: isStatic = false,
  className,
  ...props
}: TextRevealProps) {
  const ref = React.useRef<HTMLSpanElement>(null);
  const inView = useInView(ref, { once, amount: 0.4 });
  const reducedMotion = useReducedMotion() ?? false;

  // Lines of segments, plus each line's flat starting index for the stagger.
  const { lines, offsets } = React.useMemo(() => {
    const lines = children
      .split(/\r?\n/)
      .map((line) =>
        by === "word" ? line.split(/\s+/).filter(Boolean) : [line],
      );
    const offsets: number[] = [];
    let count = 0;
    for (const segments of lines) {
      offsets.push(count);
      count += segments.length;
    }
    return { lines, offsets };
  }, [children, by]);

  if (isStatic) {
    return (
      <span className={cn("whitespace-pre-line", className)} {...props}>
        {children}
      </span>
    );
  }

  return (
    <span ref={ref} className={className} {...props}>
      <span className="sr-only">{children}</span>
      <span aria-hidden="true">
        {lines.map((segments, lineIndex) => (
          <span key={lineIndex} className="block">
            {segments.map((segment, segmentIndex) => {
              const i = offsets[lineIndex] + segmentIndex;
              return (
                <React.Fragment key={segmentIndex}>
                  <span
                    className="inline-block"
                    style={{
                      opacity: inView ? 1 : 0,
                      // Reduced motion: plain fade — no movement, no blur.
                      transform: reducedMotion
                        ? undefined
                        : inView
                          ? "translateY(0px)"
                          : "translateY(12px)",
                      filter: reducedMotion
                        ? undefined
                        : inView
                          ? "blur(0px)"
                          : "blur(4px)",
                      transitionProperty: reducedMotion
                        ? "opacity"
                        : "opacity, transform, filter",
                      // Exits (once={false}, scrolled back out) run ~half,
                      // ease-exit, no stagger — resets don't fight for attention.
                      transitionDuration: inView
                        ? "var(--duration-base)"
                        : "var(--duration-quick)",
                      transitionTimingFunction: inView
                        ? "var(--ease-out)"
                        : "var(--ease-exit)",
                      transitionDelay: inView
                        ? `${delay + (reducedMotion ? 0 : i * stagger)}ms`
                        : "0ms",
                    }}
                  >
                    {segment}
                  </span>
                  {segmentIndex < segments.length - 1 ? " " : null}
                </React.Fragment>
              );
            })}
          </span>
        ))}
      </span>
    </span>
  );
}