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%; 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 (20vwandmargin-top:0.8emon 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-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.
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:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 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.