# Word wipe

Text that reads in word by word as you scroll, each word lifting from faint to fully opaque.

Framework: HTML (variant `html`)

In the Digital Design monorepo, copy the files; don't retype them. From the repo root, with <project> as the target project's folder:

```sh
mkdir -p <project>/src/scripts <project>/src/styles
cp library/src/content/snippets/word-wipe/files/src/scripts/word-wipe.ts <project>/src/scripts/word-wipe.ts
cp library/src/content/snippets/word-wipe/files/src/styles/word-wipe.css <project>/src/styles/word-wipe.css
```

## Dependencies

gsap. Install them with the project's own package manager; in this repo, `yarn workspace @cdd/<project> add gsap`.

## Setup

1. Load `src/styles/word-wipe.css` in the head. The CSS must not come from the script, or the text flashes at full strength before dimming.
2. Wrap the text in `<div data-word-wipe>`.
3. Call `initWordWipes()` from `src/scripts/word-wipe.ts` once the page has loaded. It sets up every `[data-word-wipe]`, and returns a clean-up function.
Without the script, the text stays at full strength.


## Usage

```astro
---
// Plain markup, the way a page without a framework would use it: the CSS in
// the head, and the script to set it up.
import "/src/styles/word-wipe.css";
---

<div class="spacer">Scroll down</div>
<main>
  <div data-word-wipe>
    <p class="statement">
      Each word lifts from faint to full strength as the sentence scrolls into view, then rewinds
      when you scroll back up.
    </p>
  </div>
</main>
<div class="spacer"></div>

<script>
  import { initWordWipes } from "/src/scripts/word-wipe";
  initWordWipes();
</script>

```

## API

Shared by every variant. Wrapper props use the same names in camelCase.

### Attributes

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `data-word-wipe` |  |  | Marks the element to wipe. It can be any element, and the text inside is split into words. |
| `data-word-wipe-dim` | `number` | `0.1` | Opacity of a word before it lights up. Raise it for body copy that still has to read as text. The Astro and React versions take a `dim` prop. |
| `data-word-wipe-stagger` | `seconds` | `0.12` | Delay between one word starting and the next. Larger values spread the wipe over more of the scroll. The Astro and React versions take a `stagger` prop. |
| `data-word-wipe-fade` | `seconds` | `0.45` | How long a single word takes to reach full strength. The Astro and React versions take a `fade` prop. |
| `data-word-wipe-trigger-start` | `ScrollTrigger start` | `"top 90%"` | Where the wipe begins, as the element enters from the bottom. The Astro and React versions take a `triggerStart` prop. |
| `data-word-wipe-trigger-end` | `ScrollTrigger end` | `"bottom 45%"` | Where the wipe finishes. The whole animation is scrubbed between start and end, so scrolling back up rewinds it. The Astro and React versions take a `triggerEnd` prop. |
| `data-word-wipe-state` | `"pending" \| "split"` |  | Set by the script, not by you. It is "pending" while the script waits for fonts, then "split" once the words exist. The CSS uses it to dim the text, and you can style it too. |

### Custom properties

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `--word-wipe-dim` | `number` | `0.1` | Wins over the dim setting, so a theme can set the resting opacity everywhere at once. |

## Accessibility

SplitText copies the original text into an `aria-label`, so screen readers hear the whole sentence. Reduced motion skips the effect and the text stays fully lit. The wipe is scroll driven, so it never moves on its own. The page still chooses the heading or paragraph inside. It also has to check that the dim state (`opacity: 0.1` by default) is only ever a transition, never where text people need to read comes to rest.

## SEO

Text is in the HTML and fully readable without JavaScript. The dim state starts only when the script runs. Chrome does not count content at low opacity as painted. The page still has to keep this off the largest above-the-fold text, or LCP moves later.

## Notes

GSAP 3.13 and later ship SplitText and ScrollTrigger free, so `gsap` is the only dependency.

The split waits for `document.fonts.ready`. Splitting against the fallback font measures the wrong word widths and leaves words wrapped onto the wrong lines once the web font arrives.

The HTML and Astro versions share one small script, `initWordWipes()`, found by the `data-word-wipe` attribute. The React version is its own implementation, so it follows React's rules rather than wrapping the script. All three share `word-wipe.css`.

## Files

### src/scripts/word-wipe.ts

```ts
/**
 * Word wipe: the script for the HTML and Astro versions. Load `word-wipe.css`
 * in the head as well.
 *
 *   <div data-word-wipe data-word-wipe-dim="0.15">
 *     <h2>Every word lights up as you scroll.</h2>
 *   </div>
 *
 *   import { initWordWipes } from "./word-wipe";
 *   initWordWipes(); // every [data-word-wipe] on the page
 *
 * Text reads in word by word as you scroll, each word lifting from faint to
 * fully opaque. The wipe is scrubbed: it starts when the text enters from the
 * bottom and finishes as it settles into the upper half of the viewport, so
 * scrolling back up rewinds it. Words are staggered first to last, and each
 * fades over a slice of the scroll rather than snapping.
 *
 * Attributes, each also an option to `initWordWipe` (the option wins):
 *   data-word-wipe-dim            Resting opacity of an unlit word. Default 0.1.
 *   data-word-wipe-stagger        Seconds between words. Default 0.12.
 *   data-word-wipe-fade           Seconds each word takes to light up. Default 0.45.
 *   data-word-wipe-trigger-start  ScrollTrigger start. Default "top 90%".
 *   data-word-wipe-trigger-end    ScrollTrigger end. Default "bottom 45%".
 *
 * Set by the script: data-word-wipe-state is "pending" while the script waits
 * for fonts, then "split" once the words exist.
 *
 * Custom property: --word-wipe-dim on the element wins over every dim setting,
 * so a theme can set the resting opacity everywhere at once.
 *
 * Requires `word-wipe.css` in the head. It dims the text before the split, and
 * a stylesheet added by the script would land after first paint, which flashes
 * fully lit text that then drops to dim.
 *
 * Splitting waits for `document.fonts.ready`. Splitting against the fallback
 * font measures the wrong word widths, and words wrap onto the wrong lines once
 * the web font arrives. SplitText mirrors the original text into an
 * `aria-label`, so screen readers do not read the split spans word by word.
 *
 * Honours prefers-reduced-motion: the text is never split and stays fully lit.
 *
 * Calling init twice on the same element does nothing. Each call returns a
 * clean-up function that undoes it, for pages that swap content in and out.
 */

import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";

gsap.registerPlugin(ScrollTrigger, SplitText);

export interface WordWipeOptions {
  /** Resting opacity of an unlit word. */
  dim?: number;
  /** Seconds between one word starting and the next. */
  stagger?: number;
  /** Seconds a single word takes to light up. */
  fade?: number;
  /** ScrollTrigger start position, such as "top 80%". */
  triggerStart?: string;
  /** ScrollTrigger end position, such as "bottom 55%". */
  triggerEnd?: string;
}

const STATE = "data-word-wipe-state";
const active = new WeakMap<HTMLElement, () => void>();

/** A finite number from an attribute, or undefined. */
function numberAttribute(root: HTMLElement, name: string): number | undefined {
  const value = parseFloat(root.getAttribute(name) ?? "");
  return Number.isFinite(value) ? value : undefined;
}

/** Sets up one word wipe. Returns a function that undoes it. */
export function initWordWipe(root: HTMLElement, options: WordWipeOptions = {}): () => void {
  const existing = active.get(root);
  if (existing) return existing;

  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return () => {};

  let split: SplitText | undefined;
  let cancelled = false;

  // Dims the whole block right away; the words take the dim over once they
  // exist. Marked only now, so text is never left faint by a stylesheet that
  // loaded without the script that lights it.
  root.setAttribute(STATE, "pending");

  const build = () => {
    // Cleaned up while waiting on fonts.
    if (cancelled) return;

    // Custom property, then option, then attribute, then default.
    const fromVar = parseFloat(getComputedStyle(root).getPropertyValue("--word-wipe-dim"));
    const dim = Number.isFinite(fromVar)
      ? fromVar
      : (options.dim ?? numberAttribute(root, "data-word-wipe-dim") ?? 0.1);
    const stagger = options.stagger ?? numberAttribute(root, "data-word-wipe-stagger") ?? 0.12;
    const fade = options.fade ?? numberAttribute(root, "data-word-wipe-fade") ?? 0.45;
    const start =
      options.triggerStart ?? root.getAttribute("data-word-wipe-trigger-start") ?? "top 90%";
    const end =
      options.triggerEnd ?? root.getAttribute("data-word-wipe-trigger-end") ?? "bottom 45%";

    split = SplitText.create(root, {
      type: "words",
      wordsClass: "wipe-word",
      // Re-splits when line wrapping changes; the tween returned from onSplit
      // is killed and rebuilt against the new words.
      autoSplit: true,
      onSplit: (self) =>
        gsap.fromTo(
          self.words,
          { opacity: dim },
          {
            opacity: 1,
            ease: "none",
            duration: fade,
            stagger,
            scrollTrigger: { trigger: root, start, end, scrub: true },
          },
        ),
    });

    // The words are dimmed individually now (fromTo renders immediately), so
    // the container goes back to full strength without a flash.
    root.setAttribute(STATE, "split");
  };

  // Splitting before the web font lands would measure the fallback and wrap
  // words onto the wrong lines.
  document.fonts.ready.then(build);

  const cleanUp = () => {
    cancelled = true;
    // Reverting also kills the tween and its ScrollTrigger.
    split?.revert();
    split = undefined;
    root.removeAttribute(STATE);
    active.delete(root);
  };
  active.set(root, cleanUp);
  return cleanUp;
}

/** Sets up every `[data-word-wipe]` inside `scope`. Returns one function that undoes them all. */
export function initWordWipes(scope: ParentNode = document): () => void {
  const cleanUps = Array.from(scope.querySelectorAll<HTMLElement>("[data-word-wipe]"), (root) =>
    initWordWipe(root),
  );
  return () => cleanUps.forEach((cleanUp) => cleanUp());
}

```

### src/styles/word-wipe.css

```css
/*
 * Structural CSS for word wipe. Load it in the head, not from the script:
 * it dims the text before the wipe takes over, and a stylesheet added when
 * the script runs lands after first paint, which flashes fully lit text that
 * then drops to dim.
 *
 * Neither state is set without the script, so no JavaScript means text at full
 * strength rather than text stuck faint.
 */
[data-word-wipe-state="pending"] {
  opacity: var(--word-wipe-dim, 0.1);
}

[data-word-wipe-state="split"] {
  opacity: 1;
}

```
