All components

Mask Reveal On Scroll

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

As each image row enters the viewport, a 3x3 grid of clip-path masks unveils the picture piece by piece: GSAP injects nine overlay divs per image and a ScrollTrigger timeline (start 'top 75%') animates their clip-path polygons from collapsed points to full cells in a diagonal wave (power2.out, 0.5s, staggered), producing a tiled mosaic reveal. Lenis drives smooth scrolling synced to ScrollTrigger.

How it's built

Categoryscroll
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

scroll clip-path mask-reveal image-reveal gsap scrolltrigger lenis mosaic editorial

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Mask Reveal On Scroll (3×3 clip-path mosaic image reveal)

Goal

Build a long editorial gallery page where every image reveals itself as a 3×3 grid of clip-path tiles that unfold cell-by-cell in a diagonal wave when its row scrolls into view. Each .img is layered with nine identical full-cover copies of its picture, each copy clipped to one cell of a 3×3 grid; a ScrollTrigger timeline animates the nine clip-path polygons from collapsed zero-area points (each pinned at its cell's top-left corner) out to full cells, cascading top-left → bottom-right along five anti-diagonal waves. The star effect is that per-image mosaic "tile-in" reveal. Trigger is scroll (each image row entering the viewport, one-shot). Lenis provides smooth scrolling synced to ScrollTrigger.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the ScrollTrigger plugin, plus lenis for smooth scroll:

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);

No SplitText, no CustomEase, no Three.js, no canvas. Wire Lenis to GSAP's ticker the standard way:

const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);

Layout / HTML

A single scrolling document for a fictional dystopian fashion label — use the neutral brand name "Wasteland Couture" (no real brands). Structure top to bottom:

nav                         (absolute, top; brand link left, "Shop" link right)
  a "Wasteland Couture"
  a "Shop"

section.hero
  h1 "WASTELAND COUTURE"    (giant centered display heading)

section.info                (two short right-aligned body paragraphs)
  p … p …

section.hero-imgs
  .row
    .img.img-1
    .img.img-2

section.clients             (two columns)
  .col > p "Selected Clients"
  .col
    .clients-list           (~16 <p> fictional client names)
    .clients-list           (~16 more <p> names)

section.clients-imgs        (one large full-width image row, 700px tall)
  .row > .img.img-3

section.product-filters     (a filter label row with bottom border)
  .col > p "All" / "Lighting" / "Textiles" / "Furniture" / "Accessories" / "Surfaces"
  .col (empty)

section.products            (4 rows × 4 tiles; ~half populated, half left blank)
  .row  .img  .img.img-4  .img.img-5  .img
  .row  .img.img-6  .img  .img  .img.img-7
  .row  .img  .img.img-8  .img  .img.img-9
  .row  .img.img-10  .img  .img.img-11  .img.img-12

section.about               (two body paragraphs)
  p … p …

section.about-imgs
  .row > .img.img-13 + .img.img-14

section.outro
  .row > .img.img-15 + .img.img-16 + .img.img-17

footer                      (brand name left, "© 2026" right)
  • The tiles/masks are not in the HTML — every .img starts empty; JS injects the nine .mask divs into each one. The class names .row, .img, .img-1 … .img-17, and (JS-created) .mask, .m-1 … .m-9 are load-bearing.
  • Note the products grid deliberately mixes populated tiles (.img-N) with plain .img cells that carry no background — those animate too but reveal nothing, creating a scattered, gappy editorial layout.

Styling

  • Global reset * { margin:0; padding:0; box-sizing:border-box; }. html, body { width:100%; max-width:100%; height:100%; overflow-x:hidden; background: var(--paper); color: var(--ink); font-family:"Inter", sans-serif; } — the page is dark: charcoal ground, ivory type.
  • Palette and fonts:

``css :root { --paper: #141414; /* the ground, despite the name */ --ink: #efece3; /* ivory type */ --muted: #8f8d86; --line: rgba(239, 236, 227, 0.16); --accent: #f04e23; --accent-deep: #7a1f00; } `` Space Grotesk for the headline, Inter for body copy, Space Mono for small labels.

  • a, p { text-decoration:none; font-size:17px; font-weight:400; line-height:1.55; color: var(--ink); }.
  • img { width:100%; height:100%; object-fit:cover; }.
  • nav { position:absolute; top:0; left:0; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:center; }.
  • footer { width:100%; padding:2em; display:flex; justify-content:space-between; align-items:center; margin-top:4em; }.
  • section { width:100%; padding:2em; }.
  • .row { width:100%; display:flex; gap:2em; } and .col { flex:1; display:flex; gap:1em; }.
  • .hero h1 { margin-top:1.5em; text-align:center; text-transform:uppercase; font-family:"Space Grotesk"; font-size:15vw; font-weight:600; line-height:0.9; letter-spacing:-0.03em; color: var(--ink); } — huge, near-full-width headline (20vw and margin-top:0.8em on mobile).
  • .info { display:flex; justify-content:flex-end; gap:2em; } and .info p { width:25%; } — two narrow paragraphs pushed to the right.
  • .hero-imgs { margin-top:10em; }. .clients { display:flex; }, .clients-list { flex:1; }. .clients-imgs { margin-top:4em; } and .clients-imgs .row { height:700px; } (the one oversized hero-scale image). .product-filters { padding-bottom:1em; display:flex; border-bottom:1px solid var(--line); }. .products { display:flex; flex-direction:column; gap:2em; }. .about { display:flex; } with .about p { margin-top:8em; flex:1; }. .outro .row { margin-top:8em; }.
The tile / mask CSS (critical for the effect)
  • .img { position:relative; width:100%; height:100%; aspect-ratio:4/5; } — each image cell is a 4:5 portrait box (except the 700px-tall clients row).
  • .mask { position:absolute; top:0; left:0; width:100%; height:100%; } — every mask is a full-size overlay stacked on its .img.
  • Each populated .img-N sets the SAME full-cover background on all of its .mask children (not per-cell slices — every mask holds the whole picture, background: url(...) no-repeat 50% 50%; background-size: cover;). The 3×3 tiling comes purely from clip-path; the imagery underneath is identical across the nine masks, so as each cell's polygon grows it uncovers its portion of one continuous photo.
Image ↔ tile mapping (7 photos reused across 17 populated tiles)

There are only 7 distinct source images; they repeat across the 17 .img-N classes. Wire each photo to the same tile classes so the reproduction matches:

  • image-3 → .img-1, .img-6, .img-12
  • image-4 → .img-2, .img-7, .img-14
  • image-7 → .img-3, .img-15
  • image-1 → .img-4, .img-10
  • image-2 → .img-5, .img-11, .img-16
  • image-5 → .img-8, .img-17
  • image-6 → .img-9, .img-13

GSAP effect (the important part — be exhaustive)

1. The two polygon tables (9 masks per image, 3×3 grid)

Cell coordinates snap to the thirds 0% / 33% / 66% (right/bottom edges land at 33.5% / 66.5% / 100%, the extra 0.5% overlap hiding seams). Two parallel arrays, index 0→8 mapping to cells row-major (mask .m-1=index 0 top-left … .m-9=index 8 bottom-right):

initialClipPaths — collapsed zero-area points, each pinned at its cell's TOP-LEFT corner:

const initialClipPaths = [
  "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)",        // cell 0  TL corner (0,0)
  "polygon(33% 0%, 33% 0%, 33% 0%, 33% 0%)",    // cell 1  (33,0)
  "polygon(66% 0%, 66% 0%, 66% 0%, 66% 0%)",    // cell 2  (66,0)
  "polygon(0% 33%, 0% 33%, 0% 33%, 0% 33%)",    // cell 3  (0,33)
  "polygon(33% 33%, 33% 33%, 33% 33%, 33% 33%)",// cell 4  (33,33) center
  "polygon(66% 33%, 66% 33%, 66% 33%, 66% 33%)",// cell 5  (66,33)
  "polygon(0% 66%, 0% 66%, 0% 66%, 0% 66%)",    // cell 6  (0,66)
  "polygon(33% 66%, 33% 66%, 33% 66%, 33% 66%)",// cell 7  (33,66)
  "polygon(66% 66%, 66% 66%, 66% 66%, 66% 66%)",// cell 8  (66,66)
];

finalClipPaths — the full cell rectangles (with the 0.5% overlap seams):

const finalClipPaths = [
  "polygon(0% 0%, 33.5% 0%, 33.5% 33%, 0% 33.5%)",
  "polygon(33% 0%, 66.5% 0%, 66.5% 33%, 33% 33.5%)",
  "polygon(66% 0%, 100% 0%, 100% 33%, 66% 33.5%)",
  "polygon(0% 33%, 33.5% 33%, 33.5% 66%, 0% 66.5%)",
  "polygon(33% 33%, 66.5% 33%, 66.5% 66%, 33% 66.5%)",
  "polygon(66% 33%, 100% 33%, 100% 66%, 66% 66.5%)",
  "polygon(0% 66%, 33.5% 66%, 33.5% 100%, 0% 100%)",
  "polygon(33% 66%, 66.5% 66%, 66.5% 100%, 33% 100%)",
  "polygon(66% 66%, 100% 66%, 100% 100%, 66% 100%)",
];

Because each initial polygon is all four vertices stacked on the cell's top-left corner and the final polygon is that cell's four corners, each tile grows/unfolds outward from its own top-left corner to fill its ninth of the frame.

2. Inject the masks
function createMasks() {
  document.querySelectorAll(".img").forEach((img) => {
    for (let i = 1; i <= 9; i++) {
      const mask = document.createElement("div");
      mask.classList.add("mask", `m-${i}`);
      img.appendChild(mask);
    }
  });
}
createMasks();

Every .img (populated or blank) receives nine masks .m-1 … .m-9, appended in order so their NodeList index equals the polygon index above.

3. Per-row ScrollTrigger timelines

Iterate rows, then each image in the row, then its masks. Set each mask to its collapsed initial state, then build a one-shot timeline triggered by the row:

gsap.utils.toArray(".row").forEach((row) => {
  row.querySelectorAll(".img").forEach((img) => {
    const masks = img.querySelectorAll(".mask");
    masks.forEach((mask, index) => gsap.set(mask, { clipPath: initialClipPaths[index] }));

    const tl = gsap.timeline({
      scrollTrigger: { trigger: row, start: "top 75%" },
    });

    const animationOrder = [
      [".m-1"],                    // wave 0
      [".m-2", ".m-4"],            // wave 1
      [".m-3", ".m-5", ".m-7"],    // wave 2
      [".m-6", ".m-8"],            // wave 3
      [".m-9"],                    // wave 4
    ];

    animationOrder.forEach((targets, index) => {
      tl.to(
        targets.map((cls) => img.querySelector(cls)),
        {
          clipPath: (i, el) => finalClipPaths[Array.from(masks).indexOf(el)],
          duration: 0.5,
          ease: "power2.out",
          stagger: 0.1,
        },
        index * 0.125
      );
    });
  });
});
4. Exact motion spec
  • Trigger: ScrollTrigger { trigger: row, start: "top 75%" }no end, no scrub, no pin. The timeline plays once, forward the moment the row's top reaches 75% down the viewport (i.e. 25% into view from the bottom). It does not reverse on scroll-up. Every .img in a row shares the same row trigger, so all images in that row reveal simultaneously.
  • Waves (diagonal cascade): the five animationOrder groups are anti-diagonals of the 3×3 grid. Each group's tween is placed at absolute timeline position **index * 0.125** (0, 0.125, 0.25, 0.375, 0.5 s), so waves start 0.125 s apart and overlap heavily — the reveal sweeps corner-to-corner from top-left (.m-1) to bottom-right (.m-9).
  • Per-tile tween: clip-path from its initialClipPaths[i] (collapsed point) to finalClipPaths[i] (full cell), duration: 0.5, ease: "power2.out".
  • Within-wave stagger: stagger: 0.1 — in the two-tile and three-tile waves the members fire 0.1 s apart (e.g. wave 2 .m-3.m-5.m-7).
  • Final-value lookup: the clipPath target is a function (i, el) => finalClipPaths[Array.from(masks).indexOf(el)] — it resolves each element's real index within the image's mask NodeList, so .m-3 maps to finalClipPaths[2], .m-5 to [4], etc., regardless of the order it appears inside the wave array.
  • Total per-image reveal wall-clock ≈ 0.5 (last wave offset) + 0.1 (stagger) + 0.5 (duration) ≈ 1.1 s.

Assets / images

7 distinct full-bleed images, reused across the tiles per the mapping above. They are shown via background-size: cover; background-position: 50% 50%, so any orientation works — the mask box crops them. Aim for a moody, cinematic, dystopian editorial mood: dark, high-contrast, atmospheric scenes (weathered post-apocalyptic landscapes, glowing structures against night skies, desolate terrain, industrial/avant-garde textures) so the mosaic tiles read dramatically as they unfold. One of the seven (image-7 → .img-3) fills the oversized 700px-tall full-width row, so favour a wide, hero-scale composition there. No real brands, logos, or text in the imagery. Any 7 cohesive cinematic photos in this register work.

Behavior notes

  • Reveals are one-shot and irreversible (no scrub, no toggleActions reset) — once a row has tiled in, it stays revealed.
  • No prefers-reduced-motion branch and no min-width gate in the original; it runs on desktop and mobile alike. Sections stack vertically and the effect is unchanged on narrow screens.
  • Light performance cost: pure CSS clip-path tweens, no WebGL/canvas. Blank .img cells still get masks and still animate (invisibly) — that is intended.
  • Lenis smooth scroll is synced to ScrollTrigger via gsap.ticker; keep lagSmoothing(0) so the tie-in stays frame-accurate.

Images

This component ships with 7 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/mask-reveal/img1.jpg
https://motionprompts.dev/c/mask-reveal/img2.jpg
https://motionprompts.dev/c/mask-reveal/img3.jpg
https://motionprompts.dev/c/mask-reveal/img4.jpg
https://motionprompts.dev/c/mask-reveal/img5.jpg
https://motionprompts.dev/c/mask-reveal/img6.jpg
… 1 more under https://motionprompts.dev/c/mask-reveal/

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--paper, --ink, --muted, --line, --accent, --accent-deep. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, html, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the lenis.on("scroll", ScrollTrigger.update) wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.

Adapting this to React

Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Setup that runs twice with teardown that runs never leaves you two of everything: two triggers on the same element disagreeing about the same scrub, two smooth scrollers pulling on the same wheel event. The visible symptom is jitter or doubled speed, and it will not reproduce in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.

*(1) The entry point* — The script above runs at the top level: createMasks() and the gsap.utils.toArray(".row") walk both fire the instant the module is evaluated. In React that moment is import time, before this component has rendered a single .row or .img, so both calls would run against an empty document — no error, no mosaic, nothing to debug. Move the whole body — the Lenis construction, the ticker wiring, createMasks(), and the per-row loop that builds the wave timelines — into a useEffect with an empty dependency array. Do not leave any of it in the component body: that re-runs on every render and would re-inject masks and rebuild every row's timeline each time.

*(2) Element lookups* — document.querySelectorAll(".img") inside createMasks(), gsap.utils.toArray(".row"), and every row.querySelectorAll(".img") / img.querySelectorAll(".mask") / img.querySelector(cls) that follows it assume this component owns the document. Give the component a root ref, render it on the outermost element wrapping the gallery's sections, and scope all of those lookups to it. Unscoped selectors are not a style problem here: during the StrictMode remount two copies of the row/image markup exist for an instant, and an unscoped .img query will happily inject a fresh set of nine masks into the copy that is on its way out.

*(3) Cleanup* — Wrap the initial gsap.set(mask, { clipPath: initialClipPaths[index] }) calls and the per-row, per-image gsap.timeline({ scrollTrigger: { trigger: row, ... } }) in a gsap.context scoped to the root ref, and revert that context in the cleanup:

useEffect(() => {
  const ctx = gsap.context(() => {
    /* the effect exactly as described above */
  }, rootRef);
  return () => ctx.revert();
}, []);

ctx.revert() undoes the tweens, the per-row ScrollTriggers, and the inline clip-path GSAP wrote through gsap.set — but it does not undo the DOM nodes createMasks() created. document.createElement/appendChild are plain DOM mutation, invisible to the GSAP context. A StrictMode remount that runs createMasks() a second time against the same .img elements appends nine more .mask divs on top of the nine already there, and every downstream step depends on there being exactly nine: masks.forEach((mask, index) => gsap.set(mask, { clipPath: initialClipPaths[index] })) now walks eighteen nodes against nine-entry initialClipPaths/finalClipPaths arrays, and Array.from(masks).indexOf(el) inside the wave tweens starts resolving .m-1.m-9 against whichever copy happens to sit first in DOM order rather than the set the current effect pass actually built. Guard createMasks() itself — skip an .img that already has .mask children, or clear them before re-injecting — so the count stays at nine no matter how many times the effect body runs.

gsap.ticker.add is not covered by the context: the callback that drives lenis.raf off GSAP's ticker is neither a tween nor a trigger, so ctx.revert() leaves it calling into a Lenis instance the same cleanup is about to destroy. Keep the exact function reference passed to gsap.ticker.add and call gsap.ticker.remove on that reference in the cleanup, alongside lenis.destroy().

Smooth scroll is a document-level resource: there must be exactly one Lenis instance on the page, and it must be destroyed when the component that owns it unmounts. If this gallery is one section of a larger app, lift the Lenis instance to the app shell and have this effect subscribe ScrollTrigger.update to the existing scroll event instead of constructing its own. If this component does own it, construct Lenis inside the effect and, in the same cleanup, remove the lenis.on("scroll", ScrollTrigger.update) handler, remove the ticker callback as described above, and call lenis.destroy() — so a StrictMode remount does not leave a second Lenis instance fighting the first one over the same wheel events.