Word wipe
Text that reads in word by word as you scroll, each word lifting from faint to fully opaque.
Install
pnpm add gsapSetup
- Load
src/styles/word-wipe.cssin the head. The CSS must not come from the script, or the text flashes at full strength before dimming. - Wrap the text in
<div data-word-wipe>. - Call
initWordWipes()fromsrc/scripts/word-wipe.tsonce 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.
Install
pnpm add gsapSetup
The component imports the CSS and the script. The as prop sets the element, and it is a div by default.
Install
pnpm add gsapExpects 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.
---import WordWipe from "/src/components/WordWipe.astro";---
<div class="spacer">Scroll down</div><main> <WordWipe> <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>---// 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>---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>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.ts
/* * 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.css
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
Files
/** * 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> );}Goes in src/components/WordWipe.tsx
/* * 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.css
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 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.1by 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.