# Scroll fill

> Words light up as you read down the page.

- Type: Component (scroll)
- Page: https://textlab.dev/components/scroll-fill
- Markdown: https://textlab.dev/components/scroll-fill.md
- Registry item: https://textlab.dev/r/scroll-fill.json
- Source file: `registry/textlab/scroll-fill/scroll-fill.tsx`
- Dependencies: motion
- Variants: word, char, line
- Keywords: scroll, text fill, scroll reveal, highlight on scroll, apple scroll text, reading progress

## When to use

- Manifesto paragraphs and mission statements.
- A key paragraph you want people to actually read.

## When not to use

- Every paragraph on a page; the effect loses meaning.
- Text above the fold that should be readable at once.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/scroll-fill
pnpm dlx shadcn@latest add @textlab/scroll-fill
```

The `@textlab` name needs `"registries": { "@textlab": "https://textlab.dev/r/{name}.json" }` in `components.json`. Without it, use the full URL:

```bash
npx shadcn@latest add https://textlab.dev/r/scroll-fill.json
```

Import the tokens once in your root layout: `import "@/components/textlab/tokens.css";`

### Manual

1. Install the dependencies: `npm install motion@12`
2. Copy `text-fx` (https://textlab.dev/r/text-fx.json) and this component into `components/textlab/`.

## Usage

```tsx
import { ScrollFill } from "@/components/textlab/scroll-fill/scroll-fill";

export function Example() {
  return <ScrollFill text="We believe the best interfaces disappear. You notice what you came for, not the tool in between." />;
}
```

## API reference

### ScrollFill

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"word" \| "char" \| "line"` | `"word"` | What lights up as one unit. |
| `dim` | `number` | `0.18` | Opacity of text not yet reached. |
| `container` | `RefObject<HTMLElement>` | – | Scrollable ancestor to track. Defaults to the window. |
| `offset` | `[string, string]` | `["start 0.85", "end 0.45"]` | Where filling starts and ends, in motion scroll-offset syntax. |
| `as` | `ElementType` | `"p"` | Rendered element. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |

## Accessibility

- The full paragraph is read once from a hidden copy.
- Dim text keeps its shape, so nothing is hidden from sighted readers who scroll fast.

## Motion

- Progress is tied to scroll position, not time, so it reverses when you scroll up.
- Filling starts as the paragraph enters the lower part of the viewport.

## Reduced motion

The paragraph shows at full opacity.

## Responsive behavior

- Line mode re-measures on resize.

## Performance

- Opacity only. One derived motion value per unit, no React renders while scrolling.

## Notes for AI

- Use only the props documented above. Do not invent props.
- Keep the screen-reader text and the reduced-motion branch when editing the source.
- Pick a variant from: word, char, line.

## Related

- [Read along](https://textlab.dev/components/read-along.md): A highlight follows the reading, word by word.
- [In-view title](https://textlab.dev/components/in-view-title.md): Bring a section title in as it scrolls into view.
- [Scroll skew](https://textlab.dev/components/scroll-skew.md): Type leans with the speed of your scroll.

## Also in scroll

- [Velocity marquee](https://textlab.dev/components/velocity-marquee.md): A marquee that speeds up, slows and reverses with scroll.
- [Scroll stretch](https://textlab.dev/components/scroll-stretch.md): Letters stretch tall as you scroll fast.
- [Depth layers](https://textlab.dev/components/depth-layers.md): Outline copies drift apart in depth as you scroll.

## Source

### components/textlab/scroll-fill/scroll-fill.tsx

```tsx
"use client";

import { Fragment, useRef, type CSSProperties, type ElementType, type RefObject } from "react";
import { motion, useScroll, useTransform, type MotionValue } from "motion/react";
import { SrText } from "../lib/a11y";
import { splitWords } from "../lib/split";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { groupByLine, useSplitLines } from "../lib/use-split-lines";

export type ScrollFillVariant = "word" | "char" | "line";

export interface ScrollFillProps {
  text: string;
  /** What lights up as one unit. */
  variant?: ScrollFillVariant;
  /** Opacity of text that has not been read yet. */
  dim?: number;
  /** Scrollable ancestor to track. Defaults to the window. */
  container?: RefObject<HTMLElement | null>;
  /** Where filling starts and ends, as motion scroll offsets. */
  offset?: [string, string];
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

function Unit({ progress, range, dim, children, block }: { progress: MotionValue<number>; range: [number, number]; dim: number; children: React.ReactNode; block?: boolean }) {
  const opacity = useTransform(progress, range, [dim, 1]);
  return <motion.span style={{ opacity, display: block ? "block" : undefined }}>{children}</motion.span>;
}

/**
 * Words light up as you read down the page. Progress comes straight from scroll position, so it
 * follows the reader exactly, forwards and backwards.
 */
export function ScrollFill({ text, variant = "word", dim = 0.18, container, offset = ["start 0.85", "end 0.45"], as: Tag = "p", reducedMotion = "user", className, style }: ScrollFillProps) {
  const ref = useRef<HTMLElement | null>(null);
  const reduced = useReduced(reducedMotion);
  // The offset strings are motion's scroll offset syntax; the cast keeps the public prop simple.
  const { scrollYProgress } = useScroll({ target: ref, container, offset: offset as never });
  const words = splitWords(text);
  const lines = useSplitLines(ref, words.length, variant === "line" && !reduced);

  if (reduced) {
    return (
      <Tag ref={ref} className={className} style={style}>
        {text}
      </Tag>
    );
  }

  const total = words.reduce((n, w) => n + w.chars.length, 0);
  let content: React.ReactNode;
  if (variant === "char") {
    content = words.map((word, w) => (
      <Fragment key={w}>
        <span style={{ whiteSpace: "nowrap" }}>
          {word.chars.map((char, c) => {
            const i = word.charOffset + c;
            return (
              <Unit key={c} progress={scrollYProgress} range={[i / total, (i + 1) / total]} dim={dim}>
                {char}
              </Unit>
            );
          })}
        </span>
        {w < words.length - 1 ? " " : null}
      </Fragment>
    ));
  } else if (variant === "line" && lines) {
    const groups = groupByLine(words, lines);
    content = groups.map((group, l) => (
      <Unit key={l} progress={scrollYProgress} range={[l / groups.length, (l + 1) / groups.length]} dim={dim} block>
        {group.map((word, w) => (
          <Fragment key={w}>
            <span data-tl-word="">{word.text}</span>
            {w < group.length - 1 ? " " : null}
          </Fragment>
        ))}
      </Unit>
    ));
  } else {
    content = words.map((word, w) => (
      <Fragment key={w}>
        <span data-tl-word="">
          <Unit progress={scrollYProgress} range={[w / words.length, (w + 1) / words.length]} dim={dim}>
            {word.text}
          </Unit>
        </span>
        {w < words.length - 1 ? " " : null}
      </Fragment>
    ));
  }

  return (
    <Tag ref={ref} className={className} style={style}>
      <SrText>{text}</SrText>
      <span aria-hidden="true">{content}</span>
    </Tag>
  );
}

export default ScrollFill;
```

Full library index: https://textlab.dev/llms.txt
