Text Reveal
Text Effects

Text Reveal

Reveals text per word, line, or character with the house rise enter (opacity, translateY, blur) staggered 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 every mode. */
  children: string;
  /**
   * Reveal granularity: per word, per line (split on newlines), or per
   * character. `char` pairs best with a tight 15–30ms stagger.
   */
  by?: "word" | "line" | "char";
  /** Milliseconds before the first segment enters. */
  delay?: number;
  /** Milliseconds between segments. House range is 50–80ms (15–30ms for `char`). */
  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, line, or character, triggered
 * when the element scrolls into view.
 *
 * In `char` mode each word stays wrapped in a no-break group, so lines still
 * break between words — never mid-word. 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 (words, or the whole line), each line's flat starting
  // unit index, and — for char mode — each word's char offset within its line.
  const { lines, offsets, wordOffsets } = React.useMemo(() => {
    const lines = children
      .split(/\r?\n/)
      .map((line) =>
        by === "line" ? [line] : line.split(/\s+/).filter(Boolean),
      );
    const offsets: number[] = [];
    const wordOffsets: number[][] = [];
    let count = 0;
    for (const segments of lines) {
      offsets.push(count);
      if (by === "char") {
        const perWord: number[] = [];
        let c = 0;
        for (const word of segments) {
          perWord.push(c);
          c += Array.from(word).length;
        }
        wordOffsets.push(perWord);
        count += c;
      } else {
        wordOffsets.push([]);
        count += segments.length;
      }
    }
    return { lines, offsets, wordOffsets };
  }, [children, by]);

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

  const unitStyle = (i: number): React.CSSProperties => ({
    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",
  });

  return (
    <span ref={ref} data-slot="text-reveal" 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) => {
              if (by === "char") {
                const wordStart =
                  offsets[lineIndex] + wordOffsets[lineIndex][segmentIndex];
                return (
                  <React.Fragment key={segmentIndex}>
                    {/* No-break word group: lines break between words, never mid-word. */}
                    <span className="inline-block whitespace-nowrap">
                      {Array.from(segment).map((ch, charIndex) => (
                        <span
                          key={charIndex}
                          className="inline-block"
                          style={unitStyle(wordStart + charIndex)}
                        >
                          {ch}
                        </span>
                      ))}
                    </span>
                    {segmentIndex < segments.length - 1 ? " " : null}
                  </React.Fragment>
                );
              }
              const i = offsets[lineIndex] + segmentIndex;
              return (
                <React.Fragment key={segmentIndex}>
                  <span className="inline-block" style={unitStyle(i)}>
                    {segment}
                  </span>
                  {segmentIndex < segments.length - 1 ? " " : null}
                </React.Fragment>
              );
            })}
          </span>
        ))}
      </span>
    </span>
  );
}