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
.imgstarts empty; JS injects the nine.maskdivs into each one. The class names.row,.img,.img-1 … .img-17, and (JS-created).mask,.m-1 … .m-9are load-bearing. - Note the products grid deliberately mixes populated tiles (
.img-N) with plain.imgcells 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-Nsets the SAME full-cover background on all of its.maskchildren (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%" }— noend, noscrub, nopin. 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.imgin a row shares the same row trigger, so all images in that row reveal simultaneously. - Waves (diagonal cascade): the five
animationOrdergroups 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-pathfrom itsinitialClipPaths[i](collapsed point) tofinalClipPaths[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
clipPathtarget 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-3maps tofinalClipPaths[2],.m-5to[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
toggleActionsreset) — once a row has tiled in, it stays revealed. - No
prefers-reduced-motionbranch 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-pathtweens, no WebGL/canvas. Blank.imgcells still get masks and still animate (invisibly) — that is intended. - Lenis smooth scroll is synced to ScrollTrigger via
gsap.ticker; keeplagSmoothing(0)so the tie-in stays frame-accurate.