All components

Voltlites Pinned Hero Zoom-Out Reveal

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

Open live demo ↗ Raw prompt (.md)

What it does

Hero pinned for four viewport heights where a 3x3 spotlight image grid (300% of the viewport) scales from 1 to 0.5 while each image counter-zooms from 1.25 to 1, a fixed logo shrinks from 6x (2x on mobile) and travels from bottom to top, the footer tagline blurs away, and the SplitText headline plus CTA fade in word by word. A second scrubbed ScrollTrigger on the next section slides the hero up 25% under a darkening overlay as an exit parallax; driven by manual lerp/mapRange in onUpdate with Lenis smooth scroll.

How it's built

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

scroll pin hero gallery grid zoom-out reveal logo split-text parallax lenis

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

Voltlites Scroll Animation — Pinned Hero Zoom-Out Reveal

Goal

Build a full-screen dark hero that is pinned for four viewport heights while a giant 3×3 spotlight image grid (300% of the viewport, 300svh tall) zooms out from the center. As you scroll: the whole grid scales 1 → 0.5 while every image counter-zooms 1.25 → 1 (so the images stay full-bleed inside their cells as the grid shrinks); a fixed brand logo shrinks from a huge ( on mobile) down to and travels from the bottom-left corner up toward the top; a small footer tagline blurs and fades away early; and a centered SplitText headline reveals word-by-word followed by a CTA button, each fading in as you scroll. When the next section reaches the viewport, a second scrubbed trigger slides the whole hero up 25% under a darkening black overlay as an exit parallax. All of it is driven by manual lerp + mapRange math inside onUpdate (not scrub tweens), with Lenis smooth scroll.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins ScrollTrigger and SplitText, and lenis (npm) for smooth scroll. No framework — plain Vite-style module imports:

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import Lenis from "lenis";

Register once: gsap.registerPlugin(ScrollTrigger, SplitText);. (SplitText is a now-free GSAP plugin.)

Layout / HTML

Three top-level <section>s: .hero, .studio, .connect. A .logo div sits outside the sections as a fixed overlay. Class names are load-bearing — the JS/CSS query them.

<div class="logo"><img src="…" alt="" /></div>

<section class="hero">
  <div class="hero-inner">
    <div class="hero-spotlight-gallery">
      <!-- exactly 3 columns, each with exactly 3 items = 9 images total -->
      <div class="hero-spotlight-col">
        <div class="hero-spotlight-item"><img src="…" alt="" /></div>
        <div class="hero-spotlight-item"><img src="…" alt="" /></div>
        <div class="hero-spotlight-item"><img src="…" alt="" /></div>
      </div>
      <div class="hero-spotlight-col"> …3 items… </div>
      <div class="hero-spotlight-col"> …3 items… </div>
    </div>

    <div class="hero-header">
      <h3>A living catalogue of images that shouldn't exist, collected frame by frame from the edge of the real.</h3>
      <a href="#" class="btn">Request Access</a>
    </div>

    <div class="hero-footer">
      <h5>An archive of the unreal</h5>
    </div>
  </div>
  <div class="hero-overlay"></div>
</section>

<section class="studio"><h1>Studio</h1></section>
<section class="connect"><h1>Connect</h1></section>

Notes:

  • .hero-spotlight-gallery contains exactly 3 .hero-spotlight-col, each with exactly 3 .hero-spotlight-item (each item wraps one <img>) → 9 images in a 3×3 grid.
  • The copy is neutral/fictional (an uncanny-image "archive" concept). Use it verbatim; no real brand names. The logo is a fictional studio wordmark.

Styling

Font (Google Fonts): Plus Jakarta Sans, full weight axis + italics:

@import url("https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap");

Global / reset:

  • * { margin:0; padding:0; box-sizing:border-box; }
  • img { width:100%; height:100%; object-fit:cover; }
  • body { font-family:"Plus Jakarta Sans", sans-serif; }
  • h1, h3, h5 { font-weight:500; line-height:1.25; letter-spacing:-4%; }
  • h1 { font-size: clamp(2rem, 4vw, 6rem); }
  • h3 { font-size: clamp(1.5rem, 2.5vw, 3.5rem); }
  • h5 { font-size: clamp(1.25rem, 2vw, 2.75rem); }

Palette:

  • Hero background #0f0f0f (near-black).
  • Studio section #58634a (muted olive/sage), Connect section #1c1f14 (very dark green). Both center white text.
  • CTA button a.btn: color:#000; background-color:#c4d600 (bright chartreuse/lime); padding:1rem 2rem; border-radius:0.25rem; font-weight:500; width:max-content; will-change:opacity; opacity:0 (starts invisible — GSAP fades it in).

Sections:

  • section { position:relative; width:100%; height:100svh; overflow:hidden; }
  • .studio, .connect { display:flex; justify-content:center; align-items:center; text-align:center; color:#fff; }
  • .studio { margin-top: 300svh; } — CRITICAL. This 3-viewport gap (added on top of the hero's own 100svh) is the scroll runway the pinned hero consumes. Without it there is no room to scroll through the pinned sequence.

The spotlight grid (the star of the layout):

  • .hero-spotlight-gallery: position:absolute; top:50%; left:50%; transform:translate(-50%,-50%) scale(1); width:calc(300% + 2rem); height:calc(300svh + 2rem); display:flex; gap:1rem; will-change:transform; — a grid 3× the viewport width and 3 viewport-heights tall, centered on the hero, so at rest only the middle ~1/9 is visible through the hero's overflow:hidden.
  • .hero-spotlight-col: flex:1; display:flex; flex-direction:column; height:calc(300svh + 2rem); gap:1rem;
  • .hero-spotlight-item: position:relative; flex:1; overflow:hidden; (each of the 3 cells per column shares the height equally → each cell ≈ one viewport tall).
  • .hero-spotlight-item img: position:relative; transform:scale(1.25); will-change:transform;every image starts pre-zoomed 1.25× (GSAP counter-zooms it back to 1 as the grid shrinks).

Other hero layers:

  • .logo: position:fixed; bottom:2rem; left:2rem; width:6rem; display:flex; justify-content:center; align-items:center; transform:translateY(0px) scale(6); transform-origin:bottom left; will-change:transform; z-index:10; — pinned to the bottom-left corner, initially blown up 6× and anchored by its bottom-left origin. (Mobile: scale(2).)
  • .hero-inner: position:relative; width:100%; height:100%; will-change:transform; (the layer the exit parallax translates).
  • .hero-overlay: position:absolute; inset:0; width:100%; height:100%; background:#000; opacity:0; will-change:opacity; pointer-events:none; (the darkening exit veil).
  • .hero-header: position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); text-align:center; width:45%; display:flex; flex-direction:column; align-items:center; gap:2rem; color:#fff; (headline + CTA, centered).
  • .hero-footer: position:absolute; bottom:2rem; right:2rem; width:200px; color:#fff; will-change:transform, filter; (the small tagline that blurs away).

Responsive (@media (max-width:1000px)):

  • .logo { transform: translateY(0px) scale(2); }
  • .hero-header { width:100%; padding:2rem; }
  • .hero-footer { width:200px; right:unset; left:50%; bottom:20svh; text-align:center; transform:translateX(-50%); }

GSAP effect (the important part — be exact)

This component does NOT use scrub timelines with tweened durations/eases. Instead each ScrollTrigger reads self.progress in an onUpdate and drives everything imperatively via gsap.set, computing intermediate values with two helpers. The "easing" is entirely the piecewise-linear windows defined by mapRange.

Helper functions
const lerp = (from, to, t) => from + (to - from) * t;

const mapRange = (value, rangeStart, rangeEnd) =>
  gsap.utils.clamp(0, 1, (value - rangeStart) / (rangeEnd - rangeStart));

lerp interpolates a value; mapRange remaps value from the sub-range [rangeStart, rangeEnd] onto [0, 1], clamped. This lets each element run its own progress window inside the single scroll pass.

Smooth scroll wiring (Lenis + GSAP ticker)
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
Element handles
const spotlightGallery = document.querySelector(".hero-spotlight-gallery");
const spotlightImages  = document.querySelectorAll(".hero-spotlight-item img"); // 9
const logo             = document.querySelector(".logo");
const heroFooter       = document.querySelector(".hero-footer");
const heroInner        = document.querySelector(".hero-inner");
const heroOverlay      = document.querySelector(".hero-overlay");
const heroButton       = document.querySelector(".hero-header .btn");
SplitText + header fade prep
const headlineSplit = SplitText.create(".hero-header h3", {
  type: "words",
  wordsClass: "word",
});

const headerFadeTargets = [...headlineSplit.words, heroButton]; // every word + the CTA
gsap.set(headerFadeTargets, { opacity: 0 });                    // all start invisible

const headerFadeStep     = (0.6 - 0.1) / headerFadeTargets.length; // stagger step
const headerFadeDuration = headerFadeStep * 3;                     // each fade spans 3 steps (overlapping)

Split the headline into words (class word); the fade set is all words plus the CTA button, in DOM order. headerFadeStep = 0.5 / N where N = number of targets; each target's fade window is 3 steps wide, so windows overlap and the reveal cascades word-by-word.

Mobile logo start scale (matchMedia)
const MOBILE_BREAKPOINT = 1000;
let logoStartScale = 6;
gsap.matchMedia().add(`(max-width: ${MOBILE_BREAKPOINT}px)`, () => {
  logoStartScale = 2;
  return () => (logoStartScale = 6);
});
ScrollTrigger #1 — the pinned zoom-out (main effect)
ScrollTrigger.create({
  trigger: ".hero",
  start: "top top",
  end: `+=${window.innerHeight * 4}px`, // pinned across 4 viewport heights
  pin: true,
  pinSpacing: false,                    // no spacer — .studio's 300svh margin is the runway
  onUpdate: (self) => {
    const scrollProgress = self.progress; // 0 → 1 across the 4vh pin
    …
  },
});

Inside onUpdate, compute a shared galleryProgress = mapRange(scrollProgress, 0, 0.75) — i.e. the grid/image/logo motion completes at 75% of the pin, then holds. Then apply, every frame:

  1. Gallery zoom-outgalleryScale = lerp(1, 0.5, galleryProgress); gsap.set(spotlightGallery, { scale: galleryScale }). The 300%×300svh grid scales from 1 down to 0.5, pulling the outer ring of images into view as it shrinks toward the center.
  1. Image counter-zoomimageScale = lerp(1.25, 1, galleryProgress); gsap.set(spotlightImages, { scale: imageScale }). All 9 images scale from 1.25 back to 1 in lockstep with the gallery shrink, so each cell stays full-bleed (no gaps) throughout.
  1. Logo shrink + travel

``js const logoScale = lerp(logoStartScale, 1, galleryProgress); // 6→1 (2→1 mobile) const oneRem = parseFloat(getComputedStyle(document.documentElement).fontSize); const logoScaledHeight = logo.offsetHeight * logoScale; const logoTravelDistance = window.innerHeight - logoScaledHeight - oneRem * 4; gsap.set(logo, { scale: logoScale, y: -logoTravelDistance * galleryProgress }); ` The logo scales 6 → 1 while its y moves negatively (upward) by logoTravelDistance * galleryProgress. logoTravelDistance = viewport height minus the logo's current scaled height minus 4rem of margin — so the logo rides from its bottom-left home position up to near the top edge, ending small at . (Anchored by transform-origin: bottom left`.)

  1. Footer blur-outfooterProgress = mapRange(scrollProgress, 0.05, 0.25) (its own early window, 5%→25% of the pin):

``js gsap.set(heroFooter, { scale: lerp(1, 0.75, footerProgress), filter: blur(${lerp(0, 20, footerProgress)}px), opacity: lerp(1, 0, footerProgress), }); ` The tagline scales 1 → 0.75, blurs 0 → 20px, and fades 1 → 0`, disappearing within the first quarter of the pin.

  1. Headline + CTA word-by-word fade-in — for each target at index:

``js headerFadeTargets.forEach((target, index) => { const targetStart = 0.1 + index * headerFadeStep; const targetProgress = mapRange(scrollProgress, targetStart, targetStart + headerFadeDuration); gsap.set(target, { opacity: targetProgress }); }); ` Each word's fade window starts at 0.1 + index*headerFadeStep and spans headerFadeDuration (= 3 steps). Because the step is smaller than the duration, the windows overlap: words fade in 0 → 1` in sequence from ~10% scroll onward, the CTA button (the last target) fading in last around ~60%.

ScrollTrigger #2 — exit parallax (scrubbed)
ScrollTrigger.create({
  trigger: ".studio",
  start: "top bottom", // when the studio section's top enters the bottom of the viewport
  end: "top top",      // until the studio section's top reaches the top
  scrub: true,
  onUpdate: (self) => {
    const exitProgress = self.progress; // 0 → 1
    gsap.set(heroInner,   { y: `${-25 * exitProgress}%` }); // hero slides up to -25%
    gsap.set(heroOverlay, { opacity: exitProgress });        // black veil 0 → 1
  },
});

As the .studio section rises into view, the entire .hero-inner translates up to -25% and the black .hero-overlay fades in 0 → 1, so the hero darkens and parallaxes away beneath the incoming section. scrub: true glues this to scroll position (reverses on scroll-up).

No CustomEase, no Three.js. The only plugins are ScrollTrigger + SplitText; all motion is gsap.set driven by lerp/mapRange in onUpdate (trigger #1 un-scrubbed, trigger #2 scrubbed), plus Lenis smoothing.

Assets / images

  • Spotlight grid images — full-bleed with object-fit:cover, arranged 3 columns × 3 rows (9 cells). The real set is 6 distinct photos, repeated to fill the 3×3 (cover handles any crop). All are landscape/wide (roughly 16:9) but each cell renders near square through the tall grid, so composition survives heavy cropping. Every frame shares a cohesive uncanny/surreal editorial mood — cinematic, dreamlike, "images that shouldn't exist" — but the palette deliberately spans warm and cool so the mosaic feels like a varied collection. The actual subjects and dominant colors:
  • A fanned metal garden rake laid on deep crimson/burgundy crushed velvet — near-black shadows, blood-red highlights (an eerie still life).
  • A couple sitting up in bed in a dim bedroom — warm amber lamplight, mustard-yellow quilt, emerald-green curtains (unsettling domestic scene).
  • A salmon/terracotta-pink Moorish-arched courtyard with a central olive tree and magenta bougainvillea (surreal sunlit architecture).
  • An extreme, blown-out overexposed portrait — a face dissolving into pale cream and soft pink, almost white (ambiguous, ghostly).
  • A woman in a white Victorian dress standing between two black swans on a misty pond bank — muted sage-green water, soft grey fog (folkloric, dreamlike).
  • A cropped figure in a painterly multicolor patterned garment against a flat bright orange background — bare arm on hip (bold editorial fashion).
  • 1 logo — a compact chartreuse (#C4D600) SVG wordmark on a transparent background (a fictional studio mark). It's the element that scales from 6× down to 1×, so keep it a simple, legible wordmark.

Behavior notes

  • Heights use svh (100svh, margin-top:300svh, mobile footer bottom:20svh) so mobile browser chrome doesn't break the full-screen layout.
  • The .studio margin-top:300svh is the scroll runway: with pinSpacing:false the pin inserts no spacer, so the hero (100svh) + 300svh margin = the 4-viewport (window.innerHeight * 4) pin distance. Keep them in sync.
  • Desktop-first, but the effect runs at all sizes; the mobile media query only relaxes header width, repositions the footer, and (via matchMedia) starts the logo at instead of .
  • Nothing autoplays. Trigger #1 is progress-driven (not scrubbed) but still tracks scroll both ways; trigger #2 is scrubbed. Both reverse on scroll-up. No reduced-motion handling in the original.
  • Keep the will-change hints (transform on gallery/images/logo/inner, transform, filter on footer, opacity on overlay and button) — they matter for smooth per-frame gsap.set updates.