All components

Kaitonote 3D Gallery Showcase

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A pinned spotlight section scrubbed over fifteen viewport heights with Lenis smooth scroll: twenty editorial portrait and movement images fly from deep z-space (z:-1000, scale 0) toward the viewer and scatter outward in staggered 3D perspective driven by ScrollTrigger onUpdate, while SplitText fades an intro headline out word-by-word and reveals an outro headline word-by-word, ending with a cover portrait zooming into frame.

How it's built

Category3d-webgl
Techgsap, lenis
GSAP pluginsScrollTrigger, SplitText
Complexitypage
Performance costmedium
Mobile-safeyes
Scrollhijacks scrolling

scroll pin scrub 3d perspective gallery lenis splittext stagger

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

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:

  1. <section class="intro"> — a single <h1>Visions That Move Beyond the Surface</h1>.
  2. <section class="spotlight"> containing, in this order:
  3. <div class="spotlight-images"> with exactly 20 children, each <div class="img"><img src="..." alt=""></div>.
  4. <div class="spotlight-cover-img"><img src="..." alt=""></div> — the final cover image.
  5. <div class="spotlight-intro-header"><h1>When Motion and Stillness Collide in Layers</h1></div>
  6. <div class="spotlight-outro-header"><h1>What Follows Is Not Stillness but Reverberation</h1></div>
  7. <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, background var(--light), color var(--dark). Their h1 is width: 50%; text-align: center;
  • .spotlight: background var(--dark), color var(--light).
  • .spotlight-images and .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: also will-change: transform.
  • .spotlight-intro-header, .spotlight-outro-header: position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; width: 50%;. Intro header z-index: 1, outro header z-index: 2.
  • @media (max-width: 1000px): the intro/outro h1 and both spotlight headers go to width: 100%, and the two spotlight headers get padding: 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 with gsap.set on 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" }), then gsap.set(split.words, { opacity: 1 }).
  • Outro header h1: SplitText.create(el, { type: "words" }), then gsap.set(split.words, { opacity: 0 }) and gsap.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) from self.progress (0→1) using gsap.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 (reaches z: 0 at progress 0.95).
  • scale = Math.min(1, coverProgress * 2) (clamped — reaches full size at progress ~0.825 and stays).
  • x and y stay 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 words opacity: 0.
  • Inside the window: introFadeProgress = (progress - 0.6) / 0.15. For each word index: 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 with gsap.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: 1 gives 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.