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%; height:100%; font-family:"PP Neue Montreal"; } (a clean neutral grotesque; fall back to Helvetica Neue / Inter). Background stays default white; text is black.
  • a, p { text-decoration:none; font-size:16px; font-weight:500; color:#000; }.
  • 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:3em; text-align:center; text-transform:uppercase; font-size:8.75vw; font-weight:500; letter-spacing:-0.02vw; } — huge, near-full-width headline.
  • .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 rgba(0,0,0,0.25); }. .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.