3D Gallery Showcase — Pinned Scroll Spotlight
Goal
Build a full-page scroll experience whose centerpiece is a pinned "spotlight" section: 20 images fly out of deep 3D space toward the viewer and scatter past the camera with a staggered perspective effect while an intro headline dissolves word-by-word, an outro headline reveals word-by-word, and a final full-screen cover image zooms into frame — all scrubbed by scroll across 15 viewport heights.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm), plus the GSAP plugins ScrollTrigger and SplitText, and lenis for smooth scroll. Register plugins with gsap.registerPlugin(ScrollTrigger, SplitText).
Wire Lenis to GSAP 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
Three stacked full-viewport sections:
<section class="intro">— a single<h1>Visions That Move Beyond the Surface</h1>.<section class="spotlight">containing, in this order:<div class="spotlight-images">with exactly 20 children, each<div class="img"><img src="..." alt=""></div>.<div class="spotlight-cover-img"><img src="..." alt=""></div>— the final cover image.<div class="spotlight-intro-header"><h1>When Motion and Stillness Collide in Layers</h1></div><div class="spotlight-outro-header"><h1>What Follows Is Not Stillness but Reverberation</h1></div><section class="outro">— a single<h1>The Future Begins Where This Moment Ends</h1>.
Styling
- CSS variables:
--light: #d7dbd2(pale sage off-white),--dark: #0f0f0f(near black). - Font: "Instrument Serif" from Google Fonts (regular + italic styles imported; only regular is used).
body { font-family: "Instrument Serif", sans-serif; } - Global reset:
* { margin: 0; padding: 0; box-sizing: border-box; } - All
img:width: 100%; height: 100%; object-fit: cover; - All
h1:font-size: 5rem; font-weight: 500; letter-spacing: -0.1rem; line-height: 0.9; - Every
section:position: relative; width: 100vw; height: 100svh; padding: 2rem; overflow: hidden; .intro,.outro: flex, centered both axes, backgroundvar(--light), colorvar(--dark). Theirh1iswidth: 50%; text-align: center;.spotlight: backgroundvar(--dark), colorvar(--light)..spotlight-imagesand.spotlight-cover-img:position: absolute; top: 0; left: 0; width: 100%; height: 100%; transform-style: preserve-3d; perspective: 2000px;— the perspective on these containers is what makes the z-translation of children read as 3D depth..img:position: absolute; top: 50%; left: 50%; transform: translate3d(-50%, -50%, -1000px); width: 500px; height: 350px; will-change: transform;— every tile starts stacked dead-center, pushed 1000px away from the camera..spotlight-cover-img: alsowill-change: transform..spotlight-intro-header,.spotlight-outro-header:position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; width: 50%;. Intro headerz-index: 1, outro headerz-index: 2.@media (max-width: 1000px): the intro/outroh1and both spotlight headers go towidth: 100%, and the two spotlight headers getpadding: 2rem.
GSAP effect (exhaustive)
Everything runs inside an initSpotlightAnimations() function called once on DOMContentLoaded and re-called on every window resize (recomputes screen-size-dependent positions; re-splits the headers; kills all existing ScrollTriggers via ScrollTrigger.getAll().forEach(t => t.kill()) before creating a new one).
Hard-coded scatter directions
Each of the 20 image tiles has its own scatter direction vector (multipliers of screen width/height), in DOM order:
{x: 1.3, y: 0.7}, {x: -1.5, y: 1.0}, {x: 1.1, y: -1.3}, {x: -1.7, y: -0.8},
{x: 0.8, y: 1.5}, {x: -1.0, y: -1.4}, {x: 1.6, y: 0.3}, {x: -0.7, y: 1.7},
{x: 1.2, y: -1.6}, {x: -1.4, y: 0.9}, {x: 1.8, y: -0.5}, {x: -1.1, y: -1.8},
{x: 0.9, y: 1.8}, {x: -1.9, y: 0.4}, {x: 1.0, y: -1.9}, {x: -0.8, y: 1.9},
{x: 1.7, y: -1.0}, {x: -1.3, y: -1.2}, {x: 0.7, y: 2.0}, {x: 1.25, y: -0.2}
Setup (per init)
isMobile = window.innerWidth < 1000;scatterMultiplier = isMobile ? 2.5 : 0.5.- Start position for every tile:
{ x: 0, y: 0, z: -1000, scale: 0 }— applied withgsap.seton each.img. - End position per tile:
{ x: dir.x * innerWidth * scatterMultiplier, y: dir.y * innerHeight * scatterMultiplier, z: 2000, scale: 1 }. - Cover image initial state via
gsap.set:{ x: 0, y: 0, z: -1000, scale: 0 }. - Intro header
h1:SplitText.create(el, { type: "words" }), thengsap.set(split.words, { opacity: 1 }). - Outro header
h1:SplitText.create(el, { type: "words" }), thengsap.set(split.words, { opacity: 0 })andgsap.set(h1, { opacity: 1 }).
The ScrollTrigger
One single ScrollTrigger.create:
trigger: ".spotlight",start: "top top",end: "+=" + window.innerHeight * 15 + "px"(15 viewport heights of scroll),pin: true,pinSpacing: true,scrub: 1.- No tweens/timeline — the whole effect is computed manually in
onUpdate(self)fromself.progress(0→1) usinggsap.set+gsap.utils.interpolate.
onUpdate — image scatter (progress 0 → ~0.82)
For each image index 0–19:
staggerDelay = index * 0.03(tiles start one after another).imageProgress = Math.max(0, (progress - staggerDelay) * 4)— each tile's local progress ramps 4× faster than scroll progress and is not clamped at 1, so tiles deliberately overshoot their end values and fly far past the camera.scaleMultiplier = isMobile ? 4 : 2.- Then set via
gsap.set(img, ...): z = interpolate(-1000, 2000, imageProgress)— from deep behind the focal plane to well in front of it (and beyond, since unclamped).scale = interpolate(0, 1, imageProgress * scaleMultiplier)— i.e. scale grows at 2× (desktop) / 4× (mobile) the tile progress, quickly popping to full size and continuing to grow as the tile passes the camera.x = interpolate(0, endX, imageProgress),y = interpolate(0, endY, imageProgress)— the tile drifts outward along its scatter vector while approaching.
Net visual: images bloom from a single vanishing point at screen center, swell, then scatter off in 20 different directions past the viewer's shoulders.
onUpdate — cover image (progress 0.7 → ~0.95)
coverProgress = Math.max(0, (progress - 0.7) * 4).z = -1000 + 1000 * coverProgress(reachesz: 0at progress 0.95).scale = Math.min(1, coverProgress * 2)(clamped — reaches full size at progress ~0.825 and stays).xandystay 0: the cover zooms straight in and settles filling the frame.
onUpdate — intro header word-by-word fade OUT (progress 0.6 → 0.75)
- Below progress 0.6: all words
opacity: 1. Above 0.75: all wordsopacity: 0. - Inside the window:
introFadeProgress = (progress - 0.6) / 0.15. For each wordindex:wordFadeProgress = index / totalWords,fadeRange = 0.1. - If
introFadeProgress >= wordFadeProgress + fadeRange→ opacity 0. - If
introFadeProgress <= wordFadeProgress→ opacity 1. - Otherwise →
opacity = 1 - (introFadeProgress - wordFadeProgress) / fadeRange(linear per-word fade), all applied withgsap.set. - Result: the headline dissolves left-to-right, one word at a time.
onUpdate — outro header word-by-word fade IN (progress 0.8 → 0.95)
Mirror of the intro logic: below 0.8 all words opacity: 0, above 0.95 all opacity: 1. Inside the window, outroRevealProgress = (progress - 0.8) / 0.15, same index / totalWords threshold and fadeRange = 0.1, but opacity ramps (outroRevealProgress - wordRevealProgress) / fadeRange from 0 to 1 — the headline materializes word by word over the settled cover image.
Assets / images
- 20 gallery images, landscape ~10:7 (tiles render at 500×350px): moody, cinematic editorial portraits — silhouettes, motion blur, close-up faces in dramatic colored light (greens, oranges, blues, black-and-white). Variety in tone keeps the scatter visually rich.
- 1 cover image, full-viewport (it fills the screen at the end): a bold sunlit editorial portrait with strong color that works as a hero backdrop for the final headline.
Behavior notes
- The whole page scrolls with Lenis inertia; the spotlight section stays pinned for 15 viewport heights while everything scrubs (
scrub: 1gives a ~1s catch-up lag). - On resize the animation fully re-initializes: positions are recomputed from the new viewport, headers are re-split, and all ScrollTriggers are killed and recreated.
- Mobile (< 1000px): scatter distance multiplier jumps from 0.5 to 2.5 and scale ramp from 2× to 4×, so tiles clear the smaller screen just as decisively; headers span full width.
- No hover/click interactions — the entire effect is scroll-driven.