# Word Reveal

> A headline whose words blur, slide and fade in one by one when scrolled into view, with replay.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/word-reveal
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/word-reveal.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/word-reveal-demo.tsx"
"use client"

import * as React from "react"
import { RotateCw } from "lucide-react"

import { WordReveal } from "@/components/word-reveal"

export default function WordRevealDemo() {
  const [key, setKey] = React.useState(0)

  return (
    <div className="flex w-full max-w-2xl flex-col items-center gap-8 py-6">
      <WordReveal
        replayKey={key}
        text="Interfaces that feel alive are built one small detail at a time."
        className="justify-center text-center text-3xl font-semibold tracking-tight sm:text-4xl"
      />
      <button
        type="button"
        onClick={() => setKey((k) => k + 1)}
        className="inline-flex h-9 items-center gap-2 rounded-lg border bg-background px-4 text-sm font-medium transition-colors outline-none hover:bg-accent focus-visible:ring-4 focus-visible:ring-ring/40"
      >
        <RotateCw className="size-4" /> Replay
      </button>
    </div>
  )
}
```

## Source

```tsx title="components/word-reveal.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export interface WordRevealProps extends Omit<React.ComponentProps<"p">, "children"> {
  /** The sentence to reveal */
  text: string
  /** Delay between words in ms */
  stagger?: number
  /** Duration of each word's transition in ms */
  duration?: number
  /** Blur radius in px before a word appears */
  blur?: number
  /** Vertical offset in px before a word appears */
  offset?: number
  /** Change this value to hide the words and play the reveal again */
  replayKey?: number | string
  /** Fraction of the element that must be visible to start */
  threshold?: number
  /** Only play once. If false the words hide again when scrolled out of view */
  once?: boolean
}

// Visibility flips only inside the IntersectionObserver callback. A replayKey
// change re-mounts the observer, which immediately reports the current state.
export function WordReveal({
  text,
  stagger = 70,
  duration = 700,
  blur = 8,
  offset = 14,
  replayKey,
  threshold = 0.4,
  once = true,
  className,
  ...props
}: WordRevealProps) {
  const ref = React.useRef<HTMLParagraphElement>(null)
  const [visible, setVisible] = React.useState(false)

  React.useEffect(() => {
    const el = ref.current
    if (!el) return
    let timer = 0
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          // Hide first (a no-op on first mount) so a replay restarts the transition.
          setVisible(false)
          timer = window.setTimeout(() => setVisible(true), 60)
          if (once) observer.disconnect()
        } else if (!once) {
          setVisible(false)
        }
      },
      { threshold }
    )
    observer.observe(el)
    return () => {
      window.clearTimeout(timer)
      observer.disconnect()
    }
  }, [replayKey, threshold, once])

  const words = text.split(/\s+/).filter(Boolean)

  return (
    <p ref={ref} className={cn("flex flex-wrap gap-x-[0.28em]", className)} {...props}>
      <span className="sr-only">{text}</span>
      {words.map((word, i) => (
        <span
          key={`${word}-${i}`}
          aria-hidden
          className={cn(
            "inline-block transition-[opacity,filter,transform] ease-out motion-reduce:transition-none motion-reduce:opacity-100! motion-reduce:blur-none! motion-reduce:transform-none!",
            visible ? "translate-y-0 opacity-100 blur-0" : "opacity-0"
          )}
          style={{
            transitionDuration: `${duration}ms`,
            transitionDelay: visible ? `${i * stagger}ms` : "0ms",
            ...(visible ? null : { transform: `translateY(${offset}px)`, filter: `blur(${blur}px)` }),
          }}
        >
          {word}
        </span>
      ))}
    </p>
  )
}
```
