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…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

const KEYFRAMES = `@keyframes tr-in{from{opacity:0;transform:translateY(0.4em);filter:blur(4px)}to{opacity:1;transform:translateY(0);filter:blur(0)}}`

export type TextRevealBy = "word" | "char"

export interface TextRevealProps extends React.HTMLAttributes<HTMLSpanElement> {
  text: string
  /** Split into whole words (default) or individual characters. */
  by?: TextRevealBy
  /** Extra animation-delay (ms) stacked per unit. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/text-reveal.json

Prompt

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 IntersectionObserver at threshold: 0.3 flips 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 timersindex * stagger is baked into the inline animation-delay; the browser's compositor drives the cascade, no interval or requestAnimationFrame loop needed.
  • Once vs. replayonce decides whether the observer disconnects after the first reveal or keeps watching to reset visible on 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 CSS motion-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-label on the wrapper; the split, mid-animation unit spans are aria-hidden noise to assistive tech.

On This Page