Reveals text per word or line with the house rise enter (opacity, translateY, blur) staggered 50-80ms, triggered 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 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>
);
}