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

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.