Reveals text per word, line, or character with the house rise enter (opacity, translateY, blur) staggered on scroll into view.
npx shadcn@latest add @paragon/text-reveal"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>
);
}