Workspace / component / text

Scroll Reveal Text

Paragraph text that reveals word by word as it enters the viewport, driven by scroll position rather than a timer. Ideal for editorial long-form intros.

Workspacecomponenttext

paxfx add blocks/component-text-scroll-reveallocal only

Tier: low. Reduced motion: Full paragraph renders at full opacity.

"use client";

import { useEffect, useMemo, useRef } from "react";
import { cn, useReducedMotion } from "@pax-sh/blocks";

export interface ScrollRevealTextProps {
  text?: string;
  /** Block element to render. */
  as?: "p" | "h2" | "h3" | "div";
  /** Resting opacity of words that have not revealed yet. */
  dim?: number;
  className?: string;
}

const DEFAULT_TEXT =
  "Attention is earned. Every block in this library is built by hand, tuned on an eight pixel rhythm, and bound to your brand tokens from the very first render.";

/**
 * Paragraph text that reveals word by word as it enters the viewport,
 * driven by scroll position rather than a timer. Scroll progress is
 * written to a CSS custom property from one rAF-throttled listener, and
 * each word derives its own opacity from that variable in pure CSS, so
 * React never re-renders during scroll.
 */
export function ScrollRevealText({
  text = DEFAULT_TEXT,
  as: Tag = "p",
  dim = 0.14,
  className,
}: ScrollRevealTextProps) {
  const reduced = useReducedMotion();
  const bodyRef = useRef<HTMLSpanElement | null>(null);
  const words = useMemo(() => text.split(/\s+/).filter(Boolean), [text]);
  const fadeWindow = Math.max(3 / Math.max(words.length, 1), 0.06);

  useEffect(() => {
    const node = bodyRef.current;
    if (!node || reduced) return;

    let raf = 0;

    const measure = () => {
      raf = 0;
      const rect = node.getBoundingClientRect();
      const viewport = window.innerHeight;
      const startLine = viewport * 0.88;
      const endLine = viewport * 0.38;
      const travel = startLine - endLine + rect.height;
      const progress = Math.min(Math.max((startLine - rect.top) / travel, 0), 1);
      node.style.setProperty("--pax-scroll-reveal-p", progress.toFixed(4));
    };

    const schedule = () => {
      if (raf === 0) raf = requestAnimationFrame(measure);
    };

    measure();
    window.addEventListener("scroll", schedule, { passive: true });
    window.addEventListener("resize", schedule, { passive: true });
    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener("scroll", schedule);
      window.removeEventListener("resize", schedule);
    };
  }, [reduced]);

  return (
    <Tag
      className={cn("m-0 max-w-prose text-lg leading-relaxed font-medium", className)}
      data-pax-asset="component-text-scroll-reveal"
    >
      <span className="sr-only">{text}</span>
      <span ref={bodyRef} aria-hidden="true">
        {words.map((word, i) => {
          const threshold = i / Math.max(words.length, 1);
          return (
            <span
              key={`${word}-${i}`}
              className="inline"
              style={
                reduced
                  ? undefined
                  : {
                      opacity: `max(${dim}, calc((var(--pax-scroll-reveal-p, 0) - ${threshold.toFixed(4)}) * ${(1 / fadeWindow).toFixed(2)}))`,
                    }
              }
            >
              {word}
              {i < words.length - 1 ? " " : null}
            </span>
          );
        })}
      </span>
    </Tag>
  );
}