Skip to content

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 gsap

Usage

example.tsx
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.

Unmask.tsx
"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>
  );
}

Nearest in light

Start with a background.

The backgrounds are the most finished corner of the catalogue. Take one and drop it under your own copy.

Browse backgrounds