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…
- Registry dependencies
- utils, use-prefers-reduced-motion
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
- Shared primitive: use-prefers-reduced-motion (installed automatically via
registryDependencies) — usePrefersReducedMotion() decides whether the
IntersectionObserver is created at all. If rebuilding without it: a
useSyncExternalStore over matchMedia("(prefers-reduced-motion: reduce)")
with a "change" listener and a server snapshot of false.
- 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 and starts hidden through
motion-safe:[@media(scripting:enabled)]:opacity-0 — only where the
entrance can actually run, so the server-rendered text stays legible
with JavaScript off. Only once visible and motion is not reduced does a
unit get 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 — the hidden start state is motion-safe only, so pure CSS shows
every unit immediately and reduced-motion users never wait on JS. Keep
the two conditions in one stacked variant: a separate
motion-reduce:opacity-100 would lose to the arbitrary media variant,
which Tailwind emits later.
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: a <span className="sr-only">{text}</span> inside the
root carries the whole sentence; the inner layer holding the split unit
spans is aria-hidden, since mid-animation fragments are noise for screen
readers. Do not put aria-label on the root instead: ARIA prohibits naming
a role-less (generic) span, and NVDA / JAWS browse mode would skip it.
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, and the hidden start state is scoped tomotion-safe:and@media (scripting: enabled)in pure CSS, so reduced-motion and no-JS readers see the text immediately instead of an invisible-forever state. - Screen-reader honesty — the whole sentence is exposed once via an
sr-onlytext node inside the wrapper; the split, mid-animation unit spans arearia-hiddennoise to assistive tech.