
Word Unmask
A word-by-word reveal that stays a beat ahead of the eye and never shifts layout.
Each word fades in, in reading order, on one GSAP tween driving every word off a single ticker — no clip-path wipe, no hard edge crossing the letterforms, no translate, so the paragraph's height is identical before and after and it can never cause layout shift. The stagger and a long `sine.inOut` deceleration tail keep the wave smooth instead of each word popping in.
Server-rendered fully visible. The hidden state is applied by GSAP on mount and only when motion is welcome, so with JavaScript off or reduced motion on, this is just text.
Details
- Stack
- React · TypeScript
- Depends on
- gsap
- Licence
- Free
- Added
- 11 Sep 2026
- Tags
- motion, zero-cls, reduced-motion, gsap
Its light
- 1%
- 1%
- 98%
- 1%
Code
Install
npm install gsapUsage
import { Unmask } from "./Unmask";
export default function Example() {
return (
<Unmask
as="h2"
text="A reveal that stays a beat ahead of the eye."
className="text-3xl font-semibold"
/>
);
}Component
The real source, exactly as it ships.
"use client";
import { useEffect, useRef } from "react";
/**
* Word-by-word reveal, driven by GSAP.
*
* Each word fades in, in reading order — no clip-path wipe, no hard edge
* crossing the letterforms. A masking edge sweeping across a word reads as a
* typewriter/curtain effect; a soft opacity cross-fade, staggered with a
* long deceleration tail, is what an Apple-standard text reveal actually
* uses. GSAP over CSS transitions here on purpose: one tween driving
* every word off a single ticker is cheaper than N independent
* transition-delay timers once a heading runs to a dozen-plus words, and its
* stagger + `sine.inOut` easing — a gentle accelerate/decelerate S-curve with
* no snap at either end — is what keeps the wave smooth instead of each word
* popping in (Motion's spring easing is a closer fit for a physical/gestural
* interaction — a scroll-triggered heading reveal isn't one). No translate,
* no layout shift — the paragraph's height is identical before and after the
* tween runs, which is also why it can never cause CLS.
*
* Reading order is the sequencing rule, not a decorative offset: the eye is
* already travelling left-to-right, and each word settles a beat ahead of it.
*
* Server-rendered fully visible. The hidden state is applied on mount and
* only when motion is welcome, so with JavaScript off, under reduced
* motion, or with the Inspect panel's forced-reduced-motion on, this is just
* text.
*
* GSAP is imported when the effect runs, not at module load: every section
* title uses this, so a static import put GSAP in the home page's first
* bundle, in front of the hero's LCP, for headings that only animate once
* scrolled to. The words are hidden with a plain style straight away (the
* heading is below the fold, checked above), and if GSAP never arrives they
* are shown again rather than left hidden.
*/
export function Unmask({
text,
as: Tag = "span",
id,
className = "",
/** ms between one word starting and the next. Kept short — this is a
* reading cadence, not a performance. */
step = 45,
delay = 0,
amount = 0.4,
/** Re-cover and replay if the element leaves and re-enters. Off by default:
* a heading that re-animates every scroll-back is a toy. */
once = true,
}: {
text: string;
as?: React.ElementType;
id?: string;
className?: string;
step?: number;
delay?: number;
amount?: number;
once?: boolean;
}) {
const ref = useRef<HTMLElement>(null);
const wordRefs = useRef<HTMLSpanElement[]>([]);
useEffect(() => {
const el = ref.current;
const words = wordRefs.current.filter(Boolean);
if (!el || words.length === 0) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (document.documentElement.dataset.forceReducedMotion === "on") return;
/* Already past it on load (a deep link, a restored scroll position) —
there is no reveal to perform, only a jump to the end state. */
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight * 0.75) return;
const hide = () => words.forEach((w) => (w.style.opacity = "0"));
const clear = () =>
words.forEach((w) => {
w.style.opacity = "";
w.style.willChange = "";
});
hide();
let cancelled = false;
let io: IntersectionObserver | null = null;
let tween: { kill: () => void } | null = null;
import("gsap").then(({ default: gsap }) => {
if (cancelled) return;
const play = () => {
tween = gsap.to(words, {
opacity: 1,
duration: 1.1,
ease: "sine.inOut",
stagger: step / 1000,
delay: delay / 1000,
onStart: () => gsap.set(words, { willChange: "opacity" }),
onComplete: () => gsap.set(words, { willChange: "auto" }),
});
};
io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
play();
if (once) io?.disconnect();
} else if (!once) {
tween?.kill();
hide();
}
},
{ threshold: amount, rootMargin: "0px 0px -10% 0px" }
);
io.observe(el);
}, clear);
return () => {
cancelled = true;
io?.disconnect();
tween?.kill();
clear();
};
}, [delay, step, amount, once]);
const words = text.split(/\s+/).filter(Boolean);
wordRefs.current = [];
// Tag's type is a union of every intrinsic element's props, which makes TS
// choke on the polymorphic `as` prop ("union type too complex" / children
// typed `never`). Widening to `any` here is just for JSX's per-branch
// children check — runtime behavior (and the ref/id/className props
// actually passed below) is unaffected.
const Component = Tag as any;
return (
<Component ref={ref} id={id} className={className}>
{words.map((word, i) => (
/* The separating space is a sibling text node, never inside the
inline-block — a space inside an atomic inline-block is not a line
break opportunity, and the heading would stop wrapping. */
<span key={`${word}-${i}`}>
<span
ref={(node: HTMLSpanElement | null) => {
if (node) wordRefs.current[i] = node;
}}
className="inline-block"
>
{word}
</span>
{i < words.length - 1 ? " " : ""}
</span>
))}
</Component>
);
}