Skip to content

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.

  • motion
  • zero-cls
  • reduced-motion
  • gsap
Status
Available
Licence
Free

Install

npm install gsap

Usage

Drop it straight into a page.

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>
  );
}

More components

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