Text
Text Reveal
Text that reveals word-by-word or character-by-character as it scrolls into view, with a staggered fade-up-blur entrance.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/text-reveal.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "TextReveal" component.
Contract
- Export a forwardRef span extending React.HTMLAttributes<HTMLSpanElement>.
- Props: text (string), by = "word" | "char" (default "word"), stagger
(ms of animation-delay added per unit, default 40), duration (ms,
entrance animation length shared by every unit, default 500), once
(boolean, default true — play once and stop observing; false resets
and replays every time the element re-enters the viewport).
Behavior
- Split `text` into units at mount: "word" splits on a capturing
whitespace regex so the whitespace runs stay in the array untouched
(natural line wrapping, no animation, no stagger index consumed);
"char" splits on Unicode code points (Array.from) and treats space
characters the same way as the whitespace runs in word mode. Only
non-whitespace units get a sequential index used for the stagger delay.
- Attach ONE IntersectionObserver to the root span (threshold 0.3). On
the first intersecting entry, set visible = true; when once is true,
disconnect the observer right there. When once is false, also flip
visible back to false on an entry that leaves the viewport, so the
next entry replays the entrance from scratch. Disconnect on unmount.
- Reset visible to false when text or by change, using the
adjust-state-during-render pattern (compare a previous key, no effect).
- Each unit is inline-block, starts at opacity-0, and — only once
visible and motion is not reduced — gets an inline
`animation: tr-in <duration>ms ease-out <index * stagger>ms both`,
fading in while translating up 0.4em and un-blurring from 4px.
- Read prefers-reduced-motion via useSyncExternalStore on matchMedia
(server snapshot false). When reduced, skip creating the observer
entirely — a motion-reduce:opacity-100 Tailwind class shows every unit
immediately via pure CSS, so reduced-motion users never wait on JS.
Rendering & styling
- Semantic tokens only; the component sets no font-size or color of its
own — it inherits typography from className / surrounding context.
- Ship the @keyframes via a React 19 hoisted <style href precedence> tag
— no Tailwind config edits, dedupes by href.
- Accessibility: the outer span carries aria-label={text} for the whole
sentence; the inner layer holding the split unit spans is aria-hidden,
since mid-animation fragments are noise for screen readers.
Customization levers
- Pace: stagger 20–30ms reads well for long paragraphs, 40–60ms suits
short punchy headlines — bigger gaps make the reveal feel more
deliberate.
- Direction: swap translateY(0.4em) for translateY(-0.4em) (drop from
above) or a translateX for a sideways sweep.
- Intensity: raise or lower the 4px blur, or drop the filter entirely
for a plain fade-and-rise.
- Replay: once=false to replay on every re-entry (good for demos/specs
sections users scroll past repeatedly); pair with a lower threshold
if the trigger should fire earlier.Concepts
- Whitespace passthrough — splitting keeps whitespace runs as untouched array entries instead of animated units, so words wrap exactly like normal text and the stagger index only counts visible content.
- Threshold-triggered reveal — a single
IntersectionObserveratthreshold: 0.3flips one boolean; every unit derives its own look from that boolean plus its own index, instead of each unit running its own observer. - Stagger via animation-delay, not timers —
index * staggeris baked into the inlineanimation-delay; the browser's compositor drives the cascade, no interval or requestAnimationFrame loop needed. - Once vs. replay —
oncedecides whether the observer disconnects after the first reveal or keeps watching to resetvisibleon exit, so the same entrance can be a one-shot moment or a repeatable scroll gimmick. - Reduced motion skips the machinery, not the content — under
prefers-reduced-motion, the observer is never created; a pure CSSmotion-reduce:override shows the text immediately, so there's no dependency on JS running to avoid an invisible-forever state. - Screen-reader honesty — the whole sentence is exposed once via
aria-labelon the wrapper; the split, mid-animation unit spans arearia-hiddennoise to assistive tech.