IrisComponentsWord Unmask
Word Unmask
A word-by-word reveal that stays a beat ahead of the eye and never shifts layout.
Word Unmask
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.
Install
npm install gsapUsage
Drop it straight into a page.
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";
import gsap from "gsap";
/**
* 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 by GSAP 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.
*/
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;
gsap.set(words, { opacity: 0 });
let tween: gsap.core.Tween | null = null;
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" }),
});
};
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
play();
if (once) io.disconnect();
} else if (!once) {
tween?.kill();
gsap.set(words, { opacity: 0 });
}
},
{ threshold: amount, rootMargin: "0px 0px -10% 0px" }
);
io.observe(el);
return () => {
io.disconnect();
tween?.kill();
gsap.set(words, { clearProps: "opacity,willChange" });
};
}, [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>
);
}