Word wipe

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

Status
stable
Tags
  • motion
  • text
  • scroll

Install

pnpm 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.

Wraps the HTML core, so the logic is shared.

Install

pnpm add gsap

Setup

The component imports the CSS and the script. The as prop sets the element, and it is a div by default.

Parity with the default: full

Install

pnpm add gsap

Expects in your project: 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.

Demo

Files

/**
* 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());
}

Goes in src/scripts/word-wipe.tsFrom the HTML core

/*
* 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;
}

Goes in src/styles/word-wipe.cssFrom the HTML core

---
/**
* Word wipe for Astro: the HTML version as a component. Props map one to one
* onto the data attributes; see src/scripts/word-wipe.ts for the full API.
*
* <WordWipe dim={0.15}><h2>Every word lights up as you scroll.</h2></WordWipe>
*/
import type { HTMLAttributes, HTMLTag } from "astro/types";
// Imported in frontmatter so Astro bundles it into the head, before first paint.
import "../styles/word-wipe.css";
interface Props extends HTMLAttributes<"div"> {
/** Element to render. */
as?: HTMLTag;
/** 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 { as: Tag = "div", dim, stagger, fade, triggerStart, triggerEnd, ...rest } = Astro.props;
const text = (value: number | undefined) => (value === undefined ? undefined : String(value));
---
<Tag
data-word-wipe
data-word-wipe-dim={text(dim)}
data-word-wipe-stagger={text(stagger)}
data-word-wipe-fade={text(fade)}
data-word-wipe-trigger-start={triggerStart}
data-word-wipe-trigger-end={triggerEnd}
{...rest}
>
<slot />
</Tag>
<script>
import { initWordWipes } from "../scripts/word-wipe";
// Safe to call again: each element is only set up once.
initWordWipes();
document.addEventListener("astro:page-load", () => initWordWipes());
</script>

Goes in src/components/WordWipe.astro

API

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

Attributes

NameTypeDefaultDescription
data-word-wipeMarks the element to wipe. It can be any element, and the text inside is split into words.
data-word-wipe-dimnumber0.1Opacity 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-staggerseconds0.12Delay 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-fadeseconds0.45How long a single word takes to reach full strength. The Astro and React versions take a fade prop.
data-word-wipe-trigger-startScrollTrigger 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-endScrollTrigger 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

NameTypeDefaultDescription
--word-wipe-dimnumber0.1Wins over the dim setting, so a theme can set the resting opacity everywhere at once.

Accessibility and SEO

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.