# Word wipe

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

Framework: React (variant `react`)

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/components <project>/src/styles
cp library/src/content/snippets/word-wipe/files/src/components/WordWipe.tsx <project>/src/components/WordWipe.tsx
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`.

Expects: react >=19.

## Setup

A native React version: it shares `word-wipe.css` with the other versions, and not their script. The component imports the CSS (your bundler puts it in the head) and sets everything up in one effect, so it renders safely on the server.
Keep the children static, because SplitText replaces the text nodes. To change the text, give the component a new `key`.


## Usage

```astro
---
import { WordWipe } from "/src/components/WordWipe";
---

<div class="spacer">Scroll down</div>
<main>
  <WordWipe client:load>
    <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>
  </WordWipe>
</main>
<div class="spacer"></div>

```

## 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/components/WordWipe.tsx

```tsx
/**
 * Word wipe for React: a native version, not a wrapper around the HTML script.
 * It shares `word-wipe.css` with the other versions, and not their script, so
 * it looks and moves the same way.
 *
 *   <WordWipe dim={0.15}><h2>Every word lights up as you scroll.</h2></WordWipe>
 *
 * Keep the children static. SplitText replaces the text nodes with its own
 * spans, so React must not change them afterwards. To change the text, give the
 * component a new `key`.
 *
 * It renders the same on the server and in the browser. Everything that needs
 * the browser happens in one effect, and the state attribute is set through a
 * ref, so nothing re-renders.
 */
import { useEffect, useRef, type ElementType, type HTMLAttributes } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import "../styles/word-wipe.css";

interface WordWipeProps extends HTMLAttributes<HTMLElement> {
  /** Element to render. */
  as?: ElementType;
  /** Resting opacity of a word that has not been reached yet. */
  dim?: number;
  /** Seconds between successive words lighting up. */
  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";

export function WordWipe({
  as: Tag = "div",
  dim = 0.1,
  stagger = 0.12,
  fade = 0.45,
  triggerStart = "top 90%",
  triggerEnd = "bottom 45%",
  children,
  ...rest
}: WordWipeProps) {
  const ref = useRef<HTMLElement>(null);

  useEffect(() => {
    const root = ref.current;
    if (!root) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

    gsap.registerPlugin(ScrollTrigger, SplitText);

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

    // Dims the block right away; the words take the dim over once they exist.
    root.setAttribute(STATE, "pending");

    // Splitting before the web font lands would measure the fallback and wrap
    // words onto the wrong lines.
    document.fonts.ready.then(() => {
      // Cleaned up while waiting on fonts, such as by Strict Mode.
      if (cancelled) return;

      // The custom property beats the prop, as it does in the other versions.
      const fromVar = parseFloat(getComputedStyle(root).getPropertyValue("--word-wipe-dim"));
      const from = Number.isFinite(fromVar) ? fromVar : dim;

      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: from },
            {
              opacity: 1,
              ease: "none",
              duration: fade,
              stagger,
              scrollTrigger: {
                trigger: root,
                start: triggerStart,
                end: triggerEnd,
                scrub: true,
              },
            },
          ),
      });

      root.setAttribute(STATE, "split");
    });

    return () => {
      cancelled = true;
      // Reverting also kills the tween and its ScrollTrigger.
      split?.revert();
      root.removeAttribute(STATE);
    };
  }, [dim, stagger, fade, triggerStart, triggerEnd]);

  return (
    <Tag ref={ref} data-word-wipe="" {...rest}>
      {children}
    </Tag>
  );
}

```

### 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;
}

```
