All components

HeureBleue Scroll Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

A scroll-driven zoom-through gallery: a fixed grid of five image columns sits behind the page and, as a tall spacer section scrolls past, a single ScrollTrigger (scrub 1) reads its progress and scales the whole gallery wrapper up (to ~2.65x, or 4x under 900px), pushes the side columns down and shrinks the centered main image from scale 2 to ~1.15, so the viewer appears to fly forward through the images. Lenis drives smooth scrolling synced to GSAP's ticker.

How it's built

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

scroll scrolltrigger zoom gallery lenis parallax fullscreen cinematic scrub

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

Scroll Zoom-Through Image Gallery ("Serene Drift")

Goal

Build a scroll-driven fly-through-the-images effect: a fixed 5-column grid of photos sits pinned behind the page, and as a tall transparent spacer section scrolls past, a single ScrollTrigger (scrub) reads its progress and scales the whole gallery wrapper up (1 → ~3.65x on desktop, 1 → ~5x on mobile) while it pushes the four side columns downward and shrinks the centered focal image from scale 2 down to ~1.15. The viewer appears to accelerate forward through a wall of images. Opaque editorial sections (hero, intro, outro, footer) sit above the gallery; only the transparent spacer section lets the zooming gallery show through. Lenis drives smooth scrolling synced to GSAP's ticker.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the ScrollTrigger plugin, plus lenis for smooth scrolling:

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);

No frameworks. script.js is loaded as <script type="module" src="./script.js"> at the end of <body>, so it runs after the DOM is parsed — no DOMContentLoaded wrapper is needed. There are no tweens and no timeline — the entire effect is one ScrollTrigger.create() whose onUpdate writes absolute inline style.transform strings every frame; all smoothing comes from scrub: 1 + Lenis inertia.

Layout / HTML

Two top-level blocks in <body>: first the fixed gallery (.sticky), then the scrolling page content (.container).

<body>
  <!-- Fixed gallery that lives BEHIND the page -->
  <section class="sticky">
    <div class="gallery-wrapper">
      <div class="col side-1">
        <div class="img"><img src="(img1)" alt="" /></div>
        <div class="img"><img src="(img2)" alt="" /></div>
        <div class="img"><img src="(img3)" alt="" /></div>
      </div>
      <div class="col side-2">
        <div class="img"><img src="(img4)" alt="" /></div>
        <div class="img"><img src="(img5)" alt="" /></div>
        <div class="img"><img src="(img6)" alt="" /></div>
      </div>
      <div class="col main">
        <div class="img"><img src="(img7)" alt="" /></div>
        <div class="img main"><img src="(img8)" alt="" /></div>   <!-- focal image -->
        <div class="img"><img src="(img9)" alt="" /></div>
      </div>
      <div class="col side-3">
        <div class="img"><img src="(img10)" alt="" /></div>
        <div class="img"><img src="(img11)" alt="" /></div>
        <div class="img"><img src="(img12)" alt="" /></div>
      </div>
      <div class="col side-4">
        <div class="img"><img src="(img1)" alt="" /></div>   <!-- reuse img1–img3 -->
        <div class="img"><img src="(img2)" alt="" /></div>
        <div class="img"><img src="(img3)" alt="" /></div>
      </div>
    </div>
  </section>

  <!-- Scrolling page content -->
  <div class="container">
    <section class="hero">
      <div class="hero-img"><img src="(hero)" alt="" /></div>
      <div class="header"><h1>serene</h1><h1>drift</h1></div>
    </section>

    <section class="intro">
      <div class="tagline"><p>Inspired visuals for creators of calm and beauty</p></div>
      <div class="divider"></div>
      <div class="intro-header"><h1>elevating</h1><h1>serenity</h1></div>
    </section>

    <section class="ws"></section>   <!-- the tall TRANSPARENT scroll spacer that drives everything -->

    <section class="outro"><h1>crafted calm</h1><h1>and beauty</h1></section>

    <section class="footer">
      <div class="footer-bg"><img src="(footer)" alt="" /></div>
    </section>
  </div>

  <script type="module" src="./script.js"></script>
</body>

The JS queries exactly: .gallery-wrapper, .col:not(.main) (the four side columns), .img.main img (the focal image), and .ws (the ScrollTrigger trigger). Keep all those class names exact. There are 5 columns × 3 images = 15 gallery slots but only 12 unique gallery files (side-4 reuses img1img3).

Styling

Fonts: the original uses two proprietary display faces — headings in "PP Acma" (a high-contrast editorial display face) and body/paragraph text in "PP Neue Montreal" (a clean neo-grotesque sans). Substitute any similar pair (e.g. a large elegant display serif/grotesque for h1, a neutral grotesk / system-ui for body); the look is calm, editorial, oversized headings.

Palette (exact hex):

  • #000 — background of the fixed .sticky gallery stage.
  • #fff — fallback background of each image cell, and the hero heading text color.
  • #e1dedc — warm greige/off-white background of every page section.
  • #05364c — dark teal/petrol used for text on the intro/outro sections and the divider.

Core CSS:

  • Reset * { margin:0; padding:0; box-sizing:border-box; }.
  • html, body { width:100%; height:1000vh; }the page is 1000 viewport-heights tall; this long body is what makes the whole thing scrollable. Body font is the grotesk sans.
  • img { width:100%; height:100%; object-fit:cover; }.
  • h1 { font-family:(display face); font-size:12vw; font-weight:400; line-height:1; letter-spacing:-0.025em; } — huge.
  • p { font-size:17px; font-weight:500; -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; }.

Fixed gallery stage:

  • .sticky { position:fixed; top:0; left:0; width:100vw; height:100vh; padding:0; background-color:#000; overflow:hidden; } — always full-viewport, clipping the oversized grid.
  • .gallery-wrapper { position:fixed; top:50%; left:50%; transform:translate(-50%,-50%) scale(1); width:160vw; height:100vh; display:flex; gap:4em; }wider than the viewport (160vw) and centered; scale(1) is the JS-animated resting state.
  • .col { position:relative; flex:1; height:100%; display:flex; flex-direction:column; gap:4em; will-change:transform; } — each of the 5 columns holds 3 stacked cells.
  • .img { flex:1; overflow:hidden; background-color:#fff; } — tall portrait-ish cells; object-fit:cover fills them.
  • .img.main img { position:relative; transform:scale(2); will-change:transform; }the single focal image starts pre-zoomed at scale 2 (cropped in tight) before JS touches it.

Page content:

  • .container { width:100%; height:100%; }.
  • section { position:relative; width:100vw; height:100vh; background-color:#e1dedc; padding:2em; } — every section is one opaque viewport, EXCEPT .ws (below). Because these are position:relative and come later in the DOM than the fixed .sticky, they paint on top of it and hide the gallery — the transparent .ws is the only window through to it.
  • .hero { display:flex; justify-content:flex-end; align-items:flex-end; } with .hero-img { position:absolute; top:0; left:0; width:100%; height:100%; } (full-bleed background image) and .header { position:relative; width:100%; display:flex; justify-content:space-between; color:#fff; } — the two words "serene" / "drift" pinned to opposite ends of the bottom row over the image.
  • .intro { display:flex; flex-direction:column; justify-content:space-between; align-items:center; text-align:center; padding:4em 0; background-color:#e1dedc; color:#05364c; }; .divider { width:1.5px; height:30%; background-color:#05364c; } (thin vertical rule).
  • section.ws { width:100vw; height:600vh; background-color:transparent; } — a 600vh-tall fully transparent spacer. This is the heart of the effect: as it scrolls past, the fixed gallery behind is visible and being zoomed.
  • .outro { display:flex; flex-direction:column; justify-content:center; align-items:center; text-align:center; background-color:#e1dedc; color:#05364c; }.
  • .footer with .footer-bg { position:absolute; top:0; left:0; width:100%; height:100%; } (full-bleed background image).

GSAP effect (the important part — be exhaustive)

Lenis + ticker wiring (standard)
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
The single ScrollTrigger
ScrollTrigger.create({
  trigger: ".ws",
  start: "top bottom",   // begins when the top of .ws reaches the bottom of the viewport
  end: "bottom bottom",  // ends when the bottom of .ws reaches the bottom of the viewport
  scrub: 1,              // 1s catch-up smoothing — NOT `true`
  onUpdate: (self) => { /* everything below */ },
});

start:"top bottom"end:"bottom bottom" means the tracked scroll span equals the full height of .ws (600vh). So self.progress runs 0 → 1 across those six viewport-heights of scrolling, exactly the window in which the transparent spacer exposes the gallery. There is no pin — the gallery is position:fixed, so it stays put on its own; the spacer just supplies scroll distance and the progress read-out.

onUpdate — absolute writes every frame

Inside onUpdate, re-query the elements and compute from progress = self.progress:

const galleryWrapper = document.querySelector(".gallery-wrapper");
const sideCols = document.querySelectorAll(".col:not(.main)");   // the 4 side columns
const mainImg = document.querySelector(".img.main img");         // focal image

const screenWidth = window.innerWidth;
const maxScale = screenWidth < 900 ? 4 : 2.65;

const scale = 1 + self.progress * maxScale;        // 1 → 3.65 (desktop) / 1 → 5 (mobile)
const yTranslate = self.progress * 300;            // 0 → 300 px
const mainImgScale = 2 - self.progress * 0.85;     // 2 → 1.15

galleryWrapper.style.transform = `translate(-50%, -50%) scale(${scale})`;
sideCols.forEach((col) => { col.style.transform = `translateY(${yTranslate}px)`; });
mainImg.style.transform = `scale(${mainImgScale})`;

Breakdown of the three simultaneous, purely-linear-in-progress motions:

  1. Whole-gallery zoom.gallery-wrapper keeps its translate(-50%,-50%) centering and scales 1 → 1 + maxScale. Desktop maxScale = 2.65 (final ~3.65x); if innerWidth < 900 it jumps to 4 (final ~5x) so the effect still overwhelms the narrower screen. This is the "flying forward through the images" motion.
  2. Side columns drift down — the four .col:not(.main) columns get translateY(0 → 300px), so as the grid enlarges the outer columns slide downward past the frame while the center holds — reinforces the forward-rush parallax. (The center .col.main is deliberately excluded so the focal column stays anchored.)
  3. Focal image counter-scale.img.main img starts CSS-pre-zoomed at scale(2) and is driven down to 2 - 0.85 = 1.15. As the overall gallery zooms IN, the focal photo zooms OUT, revealing more of itself — the destination image resolves into view as you arrive at it.

All three are direct linear functions of progress; there are no eases, durations, delays, staggers, timelines, or tweens anywhere — the floaty feel is entirely scrub: 1 + Lenis. Everything is fully reversible on scroll-up because each frame is an absolute write, not an accumulation.

No SplitText, no CustomEase, no lerp/rAF loop of your own (Lenis provides the rAF), no Three.js, no canvas.

Assets / images

All photos are abstract, painterly, motion-blurred human figures on flat saturated color backgrounds — thermal/long-exposure editorial art, moody and cinematic. Displayed object-fit:cover in tall portrait-ish cells.

  • 12 unique gallery images (img1img12), portrait orientation, one per grid cell, e.g.: a motion-blurred figure in profile on teal-blue; a thermal-edge silhouette reaching an arm up on terracotta; a blurred golden figure with raised hands on magenta; an outstretched teal arm on flat red; a solid black arms-spread silhouette on pale yellow; a dark-red smoke swirl on orange; a blue open-armed silhouette on a teal-to-orange gradient; img8 = a glowing red dancing nude silhouette, arms out, on an orange-to-magenta gradient — this is the focal image in the center column that scales from 2x down; ghostly motion-blurred hands on indigo; a blue seated capped figure on green; a glowing pink figure inside a light halo on purple; a golden blurred figure with directional streaks on yellow-to-red. img1img3 are reused in the 5th column.
  • 1 hero background (hero), full-bleed landscape: a moody indigo-violet abstract blur with dark outlined hands/figure reaching through the frame — sits behind the light "serene / drift" heading.
  • 1 footer background (footer), full-bleed landscape: a dark-teal thermal profile of a head-and-shoulders looking left.

Any set of cohesive, high-saturation abstract figure images works; the palette should feel jewel-toned and cinematic to match the calm/editorial mood. Neutral demo copy only (no real brands): "serene / drift", "Inspired visuals for creators of calm and beauty", "elevating / serenity", "crafted calm / and beauty".

Behavior notes

  • Reveal order down the page: hero (opaque) → intro (opaque) → .ws 600vh transparent window where the zoom plays over the fixed gallery → outro (opaque) → footer (opaque). The gallery is only ever seen through the .ws gap.
  • The 1000vh body height plus the 600vh .ws spacer set the total scroll length; don't shorten them or the zoom span collapses.
  • Mobile: the only branch is maxScale becoming 4 under innerWidth < 900 — everything else is identical. h1 at 12vw scales fluidly.
  • will-change:transform on .col, .gallery-wrapper (implicitly via constant restyle) and .img.main img keeps the per-frame transforms smooth.
  • No resize handler, no reduced-motion branch, no pinning in the original.