Masked Image Reveal Scroll Animation (Pinned Spotlight + Growing SVG Mask)
Goal
Build a cinematic scroll-story section: after an intro screen, a spotlight section pins for 7 viewport-heights. During the first half of the pin, a 300svh-tall grid of desaturated portrait photos scrolls vertically upward past a fixed centered headline. Overlapping it (progress 0.25 → 0.75), a full-viewport banner image is revealed through a CSS mask shaped like a bold inverted-Y emblem whose mask-size grows from 0% to 450% while the image inside scales down 1.5 → 1. In the final stretch (0.75 → 0.95) a second headline appears word by word (hard opacity toggles via SplitText). One ScrollTrigger with onUpdate drives everything; scroll is smoothed with Lenis.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the ScrollTrigger and SplitText plugins, plus lenis:
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import Lenis from "lenis";
Everything runs inside a DOMContentLoaded listener. Register both plugins, then wire Lenis the standard way:
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
No timelines, no tweens — the entire effect is one ScrollTrigger.create() whose onUpdate writes absolute states with gsap.set and direct style.setProperty calls.
Layout / HTML
<body>
<nav>
<img src="(logo)" alt="" />
</nav>
<section class="intro">
<div class="header"><h1>Awaken the Scroll</h1></div>
</section>
<section class="spotlight">
<div class="header"><h1>Where Frames Fade Into Fate</h1></div>
<div class="spotlight-images">
<div class="row">
<div class="img"></div>
<div class="img"><img src="(photo-1)" /></div>
<div class="img"></div>
<div class="img"><img src="(photo-2)" /></div>
</div>
<div class="row">
<div class="img"><img src="(photo-3)" /></div>
<div class="img"></div>
<div class="img"></div>
<div class="img"></div>
</div>
<div class="row">
<div class="img"></div>
<div class="img"><img src="(photo-4)" /></div>
<div class="img"><img src="(photo-5)" /></div>
<div class="img"></div>
</div>
<div class="row">
<div class="img"></div>
<div class="img"><img src="(photo-6)" /></div>
<div class="img"></div>
<div class="img"><img src="(photo-7)" /></div>
</div>
<div class="row">
<div class="img"><img src="(photo-8)" /></div>
<div class="img"></div>
<div class="img"><img src="(photo-9)" /></div>
<div class="img"></div>
</div>
</div>
<div class="mask-container">
<div class="mask-img"><img src="(banner)" alt="" /></div>
<div class="header"><h1>The Last Frame Hits Hard</h1></div>
</div>
</section>
<section class="outro">
<h1>End of Act One</h1>
</section>
<script type="module" src="./script.js"></script>
</body>
5 rows × 4 tiles = 20 .img slots, but only 9 contain an <img> in this exact scatter pattern (row 1: slots 2 & 4 · row 2: slot 1 · row 3: slots 2 & 3 · row 4: slots 2 & 4 · row 5: slots 1 & 3). The empty .img divs stay in the flow to preserve the sparse checkerboard rhythm. The JS queries .spotlight-images, .mask-container, .mask-img, and .mask-container .header h1 — keep those exact.
Styling
Google Font: "Barlow Condensed" (load all weights; 900 is the one used).
- Global reset
* { margin:0; padding:0; box-sizing:border-box; };img { width:100%; height:100%; object-fit:cover; }. h1 { text-transform:uppercase; font-family:"Barlow Condensed"; font-size:6rem; font-weight:900; line-height:0.85; letter-spacing:-0.02rem; }— huge condensed poster type.nav: glassmorphism pill —position:fixed; top:2rem; left:50%; transform:translateX(-50%); width:35%; padding:1rem 0;flex-centered,background:rgba(255,255,255,0.2); backdrop-filter:blur(10px); border:1px solid rgba(255,255,255,0.1); border-radius:0.5rem; z-index:2;. Its logo img:width:1.5rem; object-fit:contain;.- Every
section:position:relative; width:100vw; height:100svh; background:#161616; color:#fff; overflow:hidden;..spotlightoverrides background to#101010..intro/.outroare flex-centered. .header(used in intro, spotlight, and inside the mask):position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); text-align:center; width:50%; z-index:1;..spotlight-images:position:absolute; top:0; left:0; width:100vw; height:300svh;flex column withjustify-content:space-between;and initial CSS statetransform:translateY(5%)(must match the JS start value so there's no jump);will-change:transform;..row { width:100%; padding:2rem; display:flex; gap:2rem; };.img { flex:1; aspect-ratio:5/7; overflow:hidden; }; grid photos are muted:.img img { opacity:0.5; filter:saturate(0); }..mask-container:position:absolute; top:0; left:0; width:100vw; height:100svh; overflow:hidden; z-index:10;with the CSS mask:
``css -webkit-mask: url(./spotlight-mask.svg) center / contain no-repeat; mask: url(./spotlight-mask.svg) center / contain no-repeat; -webkit-mask-size: 0%; mask-size: 0%; ` (center position is what makes the reveal bloom from the middle as the size grows.) .mask-img { width:100%; height:100%; }`.
@media (max-width:1000px):h1 { font-size:4rem; };navand.headerbecomewidth:calc(100% - 4rem);;.spotlight-images { width:200vw; left:-25vw; }so the tiles keep a chunky portrait size on narrow screens.
The mask SVG
Create spotlight-mask.svg in the project: a single solid-black filled path on a transparent background — a bold inverted-Y emblem: a thick vertical stem dropping from the top edge to about half height, then splitting into two thick diagonal legs that spread down to the bottom-left and bottom-right corners (like an upward arrow / bird-foot glyph). Exact geometry:
<svg width="800" height="693" viewBox="0 0 800 693" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M800 515.749L501.926 343.832V0H297.482V343.832L0 515.749L101.926 693L399.408 521.084L697.482 693L800 515.749Z" fill="black"/>
</svg>
GSAP effect (the important part — be exhaustive)
Setup measurements (taken once on load)
const spotlightContainerHeight = spotlightImages.offsetHeight; // 300svh in px
const viewportHeight = window.innerHeight;
const initialOffset = spotlightContainerHeight * 0.05; // the CSS translateY(5%)
const totalMovement = spotlightContainerHeight + initialOffset + viewportHeight;
SplitText on the mask headline
Split .mask-container .header h1 by words:
headerSplit = SplitText.create(maskHeader, { type: "words", wordsClass: "spotlight-word" });
gsap.set(headerSplit.words, { opacity: 0 });
The single ScrollTrigger
ScrollTrigger.create({
trigger: ".spotlight",
start: "top top",
end: `+=${window.innerHeight * 7}px`, // 7 viewport-heights of pinned scroll
pin: true,
pinSpacing: true,
scrub: 1, // 1s catch-up smoothing
onUpdate: (self) => { /* three phases below */ },
});
Everything reads progress = self.progress and writes absolute values — no ease, no duration, no delay, no stagger anywhere; all ramps are linear functions of progress and the floaty feel comes entirely from scrub: 1 + Lenis inertia. Being scrub-driven, every phase is fully reversible.
Phase A — image grid scroll-through (progress 0 → 0.5): only while progress <= 0.5:
const imagesMoveProgress = progress / 0.5;
const startY = 5; // %
const endY = -(totalMovement / spotlightContainerHeight) * 100; // ≈ −138%
const currentY = startY + (endY - startY) * imagesMoveProgress;
gsap.set(spotlightImages, { y: `${currentY}%` });
The 300svh grid starts nudged 5% down and travels linearly up until it has fully exited above the viewport (its own height + the initial offset + one viewport). Past 0.5 nothing is written, so it just stays parked off-screen.
Phase B — mask reveal (progress 0.25 → 0.75, overlapping phase A): three branches:
0.25 ≤ progress ≤ 0.75:
``js const maskProgress = (progress - 0.25) / 0.5; const maskSize = ${maskProgress * 450}%; // 0% → 450% const imageScale = 1.5 - maskProgress * 0.5; // 1.5 → 1 maskContainer.style.setProperty("-webkit-mask-size", maskSize); maskContainer.style.setProperty("mask-size", maskSize); gsap.set(maskImage, { scale: imageScale }); ` The inverted-Y cutout blooms from a point at the center; once mask-size` passes ~100% the shape outgrows the viewport and the banner becomes effectively fullscreen. Meanwhile the banner image inside settles from an oversized 1.5 to its natural size — a slow push-back that sells the reveal.
progress < 0.25: hard-holdmask-size: 0%(both properties) andscale: 1.5.progress > 0.75: hard-holdmask-size: 450%andscale: 1.
Phase C — word-by-word headline (progress 0.75 → 0.95): three branches on the SplitText words:
0.75 ≤ progress ≤ 0.95:
``js const textProgress = (progress - 0.75) / 0.2; headerSplit.words.forEach((word, index) => { const wordRevealProgress = index / totalWords; gsap.set(word, { opacity: textProgress >= wordRevealProgress ? 1 : 0 }); }); `` Words pop in sequentially left-to-right as binary opacity switches (0 or 1) — a typewriter-like reveal with no fades, no stagger tweens.
progress < 0.75: all wordsopacity: 0.progress > 0.95: all wordsopacity: 1. The last 5% of the pin holds the finished frame.
Assets / images
- 9 portrait editorial photographs, 5:7 aspect — moody, cinematic people/scene shots for the grid tiles. CSS renders them desaturated (
saturate(0)) at 50% opacity, so tonal contrast matters more than color. - 1 wide cinematic banner photograph, landscape (≥16:9, full-viewport cover) — the hero image revealed through the mask; it must hold up fullscreen.
- 1 small monochrome logo mark, square-ish, transparent background (~1.5rem display size) for the glass nav pill.
- 1 SVG mask file (
spotlight-mask.svg) — created inline per the Styling section, not a photo.
Keep all copy neutral (the demo headlines above) — no real brand names.
Behavior notes
- Total experience: intro (1 viewport) → spotlight pinned for
7 × innerHeightof scroll → outro (1 viewport).pinSpacing: trueprovides the scroll runway. - Choreography overlap is the signature: the grid is still rushing upward (phase A ends at 0.5) while the mask is already blooming (phase B starts at 0.25) — the reveal punches through the moving grid.
.mask-containersits atz-index:10, so as the mask grows it covers both the grid and the "Where Frames Fade Into Fate" headline behind it. - The centered spotlight headline never animates — it's a fixed backdrop the grid scrolls past.
- Measurements are captured once on
DOMContentLoaded; no resize handler in the original. - Sections use
100svhso mobile browser chrome doesn't clip; the effect runs unchanged on mobile (≤1000px only re-sizes type, widens nav/headers, and lets the grid bleed to 200vw). - No CustomEase, no Three.js, no lerp/rAF loop beyond the standard Lenis ticker, no reduced-motion branch in the original.