# Scroll Reveal

The ScrollReveal component lights up text word by word as the reader scrolls past it, tied to scroll position rather than a timer.

- **Category:** Text
- **Docs:** https://uibeats.com/docs/text/scroll-reveal
- **Registry:** https://uibeats.com/r/scroll-reveal.json
- **License:** MIT
- **npm dependencies:** `motion`

## Installation

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

Or install the dependencies and copy the source below:

```bash
npm install motion
```

## When to use

Best on a single statement you want read slowly: a mission line, a manifesto, a section opener. The reveal is bound to the scrollbar, so the reader sets the pace and can scroll back. Keep it to one or two sentences. A full paragraph turns reading into work.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `string` | `-` | The text to reveal, as a plain string (required) |
| `restingOpacity` | `number` | `0.15` | Opacity of a word before it is reached |
| `spread` | `number` | `0.5` | How much of the viewport the reveal is spread across, 0-1 |
| `className` | `string` | `''` | Additional CSS classes for styling |

## Example usage

```tsx
"use client";
import { ScrollReveal } from "@/components/demo/text/scroll-reveal";

const ScrollRevealUsage = () => {
  return (
    <ScrollReveal className="max-w-md text-xl leading-relaxed font-semibold md:text-2xl">
      Scroll and every word arrives in turn, tied to the scrollbar rather than
      to a timer.
    </ScrollReveal>
  );
};

export default ScrollRevealUsage;
```

## Source

Save as `components/ui/scroll-reveal.tsx`.

```tsx
"use client";

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

interface ScrollRevealProps {
  children: string;
  /** Opacity of a word before it is reached. */
  restingOpacity?: number;
  /** How much of the viewport the reveal is spread across, 0-1. */
  spread?: number;
  className?: string;
}

/**
 * Text that lights up word by word as the reader scrolls past it.
 *
 * Each word maps its own slice of the container's scroll progress, so the
 * reveal tracks the scrollbar exactly: scroll back up and it un-reveals.
 * A time-based stagger would drift out of sync with the reader.
 */
export function ScrollReveal({
  children,
  restingOpacity = 0.15,
  spread = 0.5,
  className = "",
}: ScrollRevealProps) {
  const ref = useRef<HTMLParagraphElement>(null);
  const prefersReducedMotion = useReducedMotion();

  const { scrollYProgress } = useScroll({
    target: ref,
    offset: ["start 0.9", `end ${spread}`],
  });

  const words = children.split(" ");

  if (prefersReducedMotion) {
    return <p className={className}>{children}</p>;
  }

  return (
    <p ref={ref} className={`flex flex-wrap ${className}`}>
      {words.map((word, index) => {
        const start = index / words.length;
        const end = start + 1 / words.length;

        return (
          <Word
            key={`${word}-${index}`}
            progress={scrollYProgress}
            range={[start, end]}
            restingOpacity={restingOpacity}
          >
            {word}
          </Word>
        );
      })}
    </p>
  );
}

interface WordProps {
  children: string;
  progress: MotionValue<number>;
  range: [number, number];
  restingOpacity: number;
}

function Word({ children, progress, range, restingOpacity }: WordProps) {
  const opacity = useTransform(progress, range, [restingOpacity, 1]);
  // A touch of blur on the way in makes the arrival read as focus rather than
  // as a flat fade.
  const filter = useTransform(progress, range, [
    "blur(4px)",
    "blur(0px)",
  ] as const);

  return (
    <span className="relative mr-[0.25em] inline-block">
      <motion.span style={{ opacity, filter }}>{children}</motion.span>
    </span>
  );
}

export default ScrollReveal;
```

## Credits

Written by [Claude Code](https://claude.com/claude-code).
