# Word Fill

> A paragraph lights up word by word as you read down.

Word Fill is a text animation ready for your AI assistant, React or plain JavaScript, with no dependencies. Also called ink reveal, text scroll animation, scroll text highlight or text opacity scroll.

The text waits in a faint tint, then each word takes its full colour in reading order as the page scrolls, and fades again on the way back up. Links, emphasis and selection keep working; reduced motion shows it plain.

- Page: https://foundry44.dev/components/word-fill/
- Category: [Text](https://foundry44.dev/library/text/)
- Interactions: scroll
- Dependencies: none
- Licence: [MIT, free for any project](https://foundry44.dev/license/)

## Settings

| Attribute | Default | What it is |
| --- | --- | --- |
| `data-f44-dim` | `0.2` | Opacity of the words not reached yet, 0–1. |
| `data-f44-start` | `0.85` | Where the text's top must be for the first word to start filling, from the top of the viewport (0) to the bottom (1). |
| `data-f44-end` | `0.45` | Where the text's bottom must be for the last word to be full, from the top of the viewport (0) to the bottom (1). |
| `data-f44-smoothing` | `0.25` | How closely the fill follows the scroll each frame, 0–1. 1 is locked to it, lower glides behind. |

## AI prompt

# Foundry44 · Word Fill

A paragraph starts faint and each word takes full colour in reading order as the page scrolls.

Add this component to my project. Pick the setup below that matches my project and follow only that one. When it uses the code at the end, keep its logic as written (it is self-contained, with no dependencies) and adapt only the integration: JavaScript instead of TypeScript if the project has none, file names, imports, how it starts.

## Markup it expects

The element marked `data-f44="word-fill"`, with this structure inside. Texts, images and the number of items are the demo's: use mine. The `data-f44-*` attributes set options for the ready-made script only; with the code, pass the same options to `wordFill()` or as props.

```html
<p data-f44="word-fill">Foundry Studio began with a steep back garden in Ash Valley that we walked every week for a year before planting anything. We still work that way. We visit on foot, in all weathers, we plant what already grows nearby, and we come back every spring to see what the garden decided.</p>
```

## Options

| Option | Attribute | Type | Default | Description |
|---|---|---|---|---|
| `dim` | `data-f44-dim` | `number` | `0.2` | Opacity of the words not reached yet, 0–1. |
| `start` | `data-f44-start` | `number` | `0.85` | Where the text's top must be for the first word to start filling, from the top of the viewport (0) to the bottom (1). |
| `end` | `data-f44-end` | `number` | `0.45` | Where the text's bottom must be for the last word to be full, from the top of the viewport (0) to the bottom (1). |
| `smoothing` | `data-f44-smoothing` | `number` | `0.25` | How closely the fill follows the scroll each frame, 0–1. 1 is locked to it, lower glides behind. |

## Setup

- **Plain HTML, no build step**: don't paste the code below. Load the ready-made script once, at the end of `<body>`: `<script src="https://foundry44.dev/demos/word-fill/word-fill.js" defer></script>` (or download that file and serve it with the page). It starts itself on every `data-f44="word-fill"` element and reads the `data-f44-*` attributes above.
- **React / Next.js with shadcn/ui** (the project has a `components.json`; otherwise, see the next line): `npx shadcn@latest add https://foundry44.dev/r/word-fill.json` writes `lib/word-fill-core.ts` and `components/ui/word-fill.tsx`, then use `<WordFill />` with the options as props.
- **React / Next.js from npm** (no shadcn/ui, or the user prefers a package): `npm install @foundry44/react` (or the project's package manager), then `import { WordFill } from "@foundry44/react"` with the options as props. With a bundler and no React: `@foundry44/core`, then `import { wordFill } from "@foundry44/core/word-fill"`.
- **React / Next.js by hand**: save the code below as `lib/word-fill-core.ts` and import it from there (`@/lib/word-fill-core` where that alias exists). Don't name it `word-fill.ts` next to a `WordFill.tsx`: on macOS and Windows the two names collide. In a component marked `"use client"`, call `wordFill(ref.current, options)` in a `useEffect` and return `instance.destroy` as its cleanup; call `instance.update(options)` when options change.
- **Any other stack**: create on mount with `wordFill(element, options)`, `update(options)` when they change, `destroy()` on unmount.

Only the ready-made script finds `data-f44` elements and starts them by itself. Pasted code does not: call `wordFill(element)` on each element.

## Pitfalls

- Respects prefers-reduced-motion: keep that behaviour.
- Each word becomes a <span>: style the paragraph, not the words, and don't rely on the text node structure while the effect is active.
- In React, the words are rewritten in the DOM: pass the text as static children and remount the component (change its `key`) to change it.
- Use a large size and a solid colour: the faint words are the same colour at low opacity, so keep the dim level readable on your background.
- With reduced motion the text is shown at full colour and left untouched.

## Code: `word-fill-core.ts`

```ts
// Copyright (c) 2026 Foundry44 · MIT License (SPDX-License-Identifier: MIT)
/**
 * Foundry44 — word-fill
 * A paragraph starts faint. As the page scrolls, its words take on full
 * colour one after the other, in reading order, like a finger following the
 * text. Scrolling back up fades them again.
 *
 * Each word is wrapped in a <span> whose opacity follows the scroll; inline
 * markup (links, <em>) is kept and the text stays selectable and indexable.
 *
 * Self-contained: no imports, so this file can be copied as-is.
 */

export interface WordFillOptions {
  /** Opacity of the words not reached yet, 0–1. @range 0 1 0.01 */
  dim?: number;
  /** Where the text's top must be for the first word to start filling, from the top of the viewport (0) to the bottom (1). @range 0 1 0.01 */
  start?: number;
  /** Where the text's bottom must be for the last word to be full, from the top of the viewport (0) to the bottom (1). @range 0 1 0.01 */
  end?: number;
  /** How closely the fill follows the scroll each frame, 0–1. 1 is locked to it, lower glides behind. @range 0.02 1 0.01 */
  smoothing?: number;
}

export interface WordFillInstance {
  update(options: WordFillOptions): void;
  destroy(): void;
}

export const wordFillDefaults: Required<WordFillOptions> = {
  dim: 0.2,
  start: 0.85,
  end: 0.45,
  smoothing: 0.25,
};

// A word fades in over this many words of progress, so the fill reads as a soft wave, not a hard edge.
const FADE_WORDS = 3;
// Below this progress difference (in words) the fill is at rest and the loop stops.
const REST = 0.01;

export function wordFill(el: HTMLElement, options: WordFillOptions = {}): WordFillInstance {
  let opts = { ...wordFillDefaults, ...options };

  const noop: WordFillInstance = { update() {}, destroy() {} };
  if (typeof window === "undefined") return noop;
  // The resting text is the full text, so reduced motion simply never dims it.
  if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return noop;

  // Each original text node and the nodes that replaced it, so destroy() can put it back.
  const replaced: { original: Text; parts: Node[] }[] = [];
  const words: HTMLSpanElement[] = [];
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
  const textNodes: Text[] = [];
  for (let node = walker.nextNode(); node; node = walker.nextNode()) textNodes.push(node as Text);
  for (const original of textNodes) {
    if (!original.data.trim()) continue;
    const parts: Node[] = [];
    // Whitespace stays plain text, so line breaking and copy-paste are unchanged.
    for (const piece of original.data.split(/(\s+)/)) {
      if (!piece) continue;
      if (/^\s+$/.test(piece)) {
        parts.push(document.createTextNode(piece));
        continue;
      }
      const word = document.createElement("span");
      word.textContent = piece;
      words.push(word);
      parts.push(word);
    }
    original.replaceWith(...parts);
    replaced.push({ original, parts });
  }

  let current = 0;
  let target = 0;
  let frame = 0;
  let observer: IntersectionObserver | null = null;

  // Progress in words: 0 when the top reaches `start`, the word count when the bottom reaches `end`.
  const measure = () => {
    const rect = el.getBoundingClientRect();
    const viewport = window.innerHeight || 1;
    const from = opts.start * viewport - rect.top;
    const span = rect.height + (opts.start - opts.end) * viewport;
    const progress = span > 0 ? from / span : from >= 0 ? 1 : 0;
    target = Math.min(Math.max(progress, 0), 1) * (words.length + FADE_WORDS);
  };

  const render = () => {
    const dim = Math.min(Math.max(opts.dim, 0), 1);
    words.forEach((word, index) => {
      const fill = Math.min(Math.max((current - index) / FADE_WORDS, 0), 1);
      const opacity = (dim + (1 - dim) * fill).toFixed(3);
      // Only touch words whose value changed: most of a long paragraph is static on any given frame.
      if (word.style.opacity !== opacity) word.style.opacity = opacity;
    });
  };

  const tick = () => {
    const amount = Math.min(Math.max(opts.smoothing, 0.01), 1);
    current += (target - current) * amount;
    if (Math.abs(target - current) < REST) {
      current = target;
      render();
      frame = 0;
      return;
    }
    render();
    frame = requestAnimationFrame(tick);
  };

  const onScroll = () => {
    measure();
    if (!frame) frame = requestAnimationFrame(tick);
  };

  const listen = () => {
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll, { passive: true });
    onScroll();
  };

  const unlisten = () => {
    window.removeEventListener("scroll", onScroll);
    window.removeEventListener("resize", onScroll);
    if (frame) cancelAnimationFrame(frame);
    frame = 0;
  };

  // Start at the right fill, so a page loaded mid-scroll doesn't show the words sweeping in.
  measure();
  current = target;
  render();

  if (typeof IntersectionObserver === "undefined") {
    listen();
  } else {
    // Scroll work only while the text is on screen.
    observer = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) listen();
        else unlisten();
      }
    });
    observer.observe(el);
  }

  return {
    update(next) {
      opts = { ...opts, ...next };
      measure();
      current = target;
      render();
    },
    destroy() {
      observer?.disconnect();
      unlisten();
      for (const { original, parts } of replaced) {
        const [first, ...rest] = parts;
        first?.parentNode?.replaceChild(original, first);
        for (const part of rest) part.parentNode?.removeChild(part);
      }
    },
  };
}
```
