Workspace / component / text
Scroll Reveal Text
paxfx add blocks/component-text-scroll-reveallocal onlyTier: 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>
);
}