# Word Reveal (`word-reveal`)

> A paragraph that reveals word-by-word as it scrolls through the viewport — ink rising to full strength, scrubbed directly to scroll position.

- **Docs:** https://www.mellowui.com/components/word-reveal
- **Markdown:** https://www.mellowui.com/components/word-reveal.md
- **Registry:** https://www.mellowui.com/r/word-reveal.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/word-reveal
- **Categories:** text, scroll, animation
- **Dependencies:** motion

## AI prompt

Add a WordReveal component from the mellow library. It splits the text prop into words and scrubs each word's opacity from fromOpacity to 1 as the element scrolls through the viewport, using motion/react useScroll. Wrap a word in asterisks (*quietly*) to style it with accentClassName (defaults to serif italic). Props: text, as ('p'|'h1'|'h2'|'h3'|'h4'), fromOpacity (default 0.13), offset (useScroll offset pair, default ['start 0.8','start 0.3']), overlap (words revealed together, default 3), accentClassName, className. Respects prefers-reduced-motion by rendering at full opacity.

## Install

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

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `text` | `string` | — | The paragraph to reveal. Wrap a word in asterisks (*word*) for the accent style. |
| `as` | `"p" \| "h1" \| "h2" \| "h3" \| "h4"` | `"p"` | Element type used for the wrapper. |
| `fromOpacity` | `number` | `0.13` | Opacity of words before they're revealed. |
| `offset` | `[string, string]` | `["start 0.8", "start 0.3"]` | Viewport offset pair passed to useScroll — where the reveal starts and ends. |
| `overlap` | `number` | `3` | How many words overlap in the reveal window — higher is a softer wave. |
| `accentClassName` | `string` | — | Class applied to *accented* words. Defaults to serif italic. |
| `className` | `string` | — | Additional CSS classes for the wrapper. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useMemo, useRef } from "react";
import {
  motion,
  useScroll,
  useTransform,
  useReducedMotion,
  type MotionValue,
} from "motion/react";

export interface WordRevealProps {
  /** The paragraph to reveal. Wrap a word in asterisks (`*quietly*`) to give it the accent style. */
  text: string;
  as?: "p" | "h1" | "h2" | "h3" | "h4";
  /** Opacity of words before they're revealed. */
  fromOpacity?: number;
  /** Viewport offset where the reveal starts/ends — passed to useScroll. */
  offset?: [string, string];
  /** How many words overlap in the reveal window (higher = softer wave). */
  overlap?: number;
  /** Scrollable ancestor to scrub against. Defaults to the window viewport. */
  containerRef?: React.RefObject<HTMLElement | null>;
  className?: string;
  /** Class applied to `*accented*` words. Defaults to serif italic. */
  accentClassName?: string;
}

function Word({
  children,
  progress,
  range,
  fromOpacity,
  className,
}: {
  children: string;
  progress: MotionValue<number>;
  range: [number, number];
  fromOpacity: number;
  className?: string;
}) {
  const opacity = useTransform(progress, range, [fromOpacity, 1]);
  return (
    <motion.span style={{ opacity }} className={className}>
      {children}{" "}
    </motion.span>
  );
}

/**
 * A paragraph that reveals word-by-word as it scrolls through the viewport —
 * ink rising to full strength, scrubbed directly to scroll position.
 */
export function WordReveal({ as: Tag = "p", ...rest }: WordRevealProps) {
  // Keyed on Tag so switching the wrapper element (e.g. h2 -> h4) fully
  // remounts this subtree instead of swapping the host element type under a
  // persistent ref. Without this, useScroll's scroll-position tracking stays
  // bound to the old (now-detached) node and the reveal stops updating.
  return <WordRevealBody key={Tag} as={Tag} {...rest} />;
}

function WordRevealBody({
  text,
  as: Tag = "p",
  fromOpacity = 0.13,
  offset = ["start 0.8", "start 0.3"],
  overlap = 3,
  containerRef,
  className,
  accentClassName = "[font-family:var(--font-serif)] font-normal tracking-[-0.01em] italic",
}: WordRevealProps) {
  const ref = useRef<HTMLElement>(null);
  const reduced = useReducedMotion();

  const { scrollYProgress } = useScroll({
    target: ref,
    container: containerRef ?? undefined,
    // motion's offset type is stricter than its docs; the string pairs are valid
    offset: offset as never,
  });

  const words = useMemo(
    () =>
      text
        .split(/\s+/)
        .filter(Boolean)
        .map((raw) => {
          const accent = /^\*.+\*[.,;:!?—]*$/.test(raw);
          return { word: raw.replace(/\*/g, ""), accent };
        }),
    [text]
  );

  if (reduced) {
    return (
      <Tag ref={ref as never} className={className}>
        {words.map((w, i) => (
          <span key={i} className={w.accent ? accentClassName : undefined}>
            {w.word}{" "}
          </span>
        ))}
      </Tag>
    );
  }

  const n = words.length;
  return (
    <Tag ref={ref as never} className={className}>
      {words.map((w, i) => {
        const start = i / (n + overlap);
        const end = Math.min((i + overlap) / (n + overlap), 1);
        return (
          <Word
            key={i}
            progress={scrollYProgress}
            range={[start, end]}
            fromOpacity={fromOpacity}
            className={w.accent ? accentClassName : undefined}
          >
            {w.word}
          </Word>
        );
      })}
    </Tag>
  );
}

export default WordReveal;

```

## Demo

```tsx
"use client";

import React from "react";
import { WordReveal } from "../mellow/word-reveal";

export default function WordRevealDemo() {
  return (
    <div className="flex h-full w-full items-center justify-center px-6 py-10">
      <WordReveal
        text="Most interfaces shout. Mellow moves *quietly* — texture, timing, and the small *theatre* of a cursor crossing a surface. Read it. Patch it. Make it *yours.*"
        className="max-w-[28ch] text-[clamp(1.375rem,3.2vw,2.25rem)] leading-[1.3] font-medium tracking-[-0.02em] text-(--ink)"
      />
    </div>
  );
}

```
