All components

Scroll Wave Image Gallery

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

Open live demo ↗ Raw prompt (.md)

What it does

Vertical gallery of 12 JS-generated images that drift horizontally in a sine-wave pattern as you scroll: each image has its own ScrollTrigger whose progress drives a translateX composed of three layered sine waves (base/flow/detail) scaled to viewport width, plus a horizontal clip-path inset that opens fully when the image is centered on screen. Lenis smooth scroll is hooked into the GSAP ticker, and the last quarter of images progressively shrink in height.

How it's built

Categorygallery
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitysection
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

scroll gallery wave sine clip-path lenis scrolltrigger images

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

Build: Scroll Wave Image Gallery

Goal

A tall, single-column vertical gallery of 12 images that drift left and right in a rippling sine-wave pattern as you scroll. Each image is its own ScrollTrigger: as it travels through the viewport, three layered sine waves (a slow base swell, a fast flow oscillation, and a fine detail jitter) are summed and scaled to viewport width to drive its horizontal translate, while a symmetric left/right clip-path inset closes the image to a narrow slit at the top and bottom of its travel and opens it fully wide exactly when it is centered on screen. A short intro title screen and outro title screen bookend the gallery. Smooth scroll is provided by Lenis wired into GSAP's ticker. The star effect is the organic, wave-like horizontal shimmer of the whole stack combined with the center-focused clip reveal.

Tech

Vanilla HTML/CSS/JS with ES module imports (Vite/npm project). Use gsap (npm) plus the single GSAP plugin ScrollTrigger, and lenis (npm) for smooth scroll. No other plugins, no framework, no SplitText/CustomEase/Three.js.

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

gsap.registerPlugin(ScrollTrigger);

Layout / HTML

Three full-height <section>s. The middle one has an empty container that the JS fills with 12 image tiles at runtime. Class names are load-bearing.

<section class="intro">
  <h1>Loose Structure</h1>
</section>

<section class="spotlight">
  <div class="spotlight-images"></div>   <!-- JS injects 12 .spotlight-image tiles here -->
</section>

<section class="outro">
  <h1>Crafted by Motionprompts</h1>
</section>

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

Use the neutral copy above verbatim ("Loose Structure" intro, "Crafted by Motionprompts" outro). "Motionprompts" is the fictional demo name — no real client/brand names.

Styling

Font (Google Fonts): Cossette Titre (weights 400 & 700) for the two <h1>s. A serif/display fallback is fine if that face is unavailable.

@import url("https://fonts.googleapis.com/css2?family=Cossette+Titre:wght@400;700&display=swap");

Palette (CSS variables):

  • --bg: #e3e4d8 — warm pale sage/off-white page background.
  • --fg: #000 — near-black text.

Global / reset:

  • * { margin:0; padding:0; box-sizing:border-box; }
  • body { background: var(--bg); color: var(--fg); }
  • img { width:100%; height:100%; object-fit:cover; }
  • h1 { font-family:"Cossette Titre", sans-serif; font-size: clamp(5rem, 6vw, 7rem); font-weight:500; letter-spacing:-1%; line-height:1; text-align:center; }

Sections:

  • section { position:relative; width:100%; height:100svh; padding:2rem; overflow:hidden; } — note the overflow:hidden, which clips the horizontal wave drift so images never trigger a horizontal page scrollbar.
  • .intro, .outro { display:flex; justify-content:center; align-items:center; } — the title centered.
  • .spotlight { height:100%; } — CRITICAL: this class-level height:100% overrides the section's height:100svh. Because <body> has no fixed height, 100% resolves to auto, so the spotlight section grows to the full stacked height of its 12 tiles (~4000px+). That tall middle section IS the scroll runway. Do not clamp it to one viewport.

Gallery containers:

  • .spotlight-images { display:flex; flex-direction:column; align-items:flex-start; } — a single left-aligned vertical column of tiles (the JS then shifts each one horizontally via translate).
  • .spotlight-image { position:relative; clip-path: inset(0 20% 0 20%); will-change: transform, clip-path; overflow:hidden; }each tile starts clipped 20% in from the left and 20% from the right (a narrow center slit). This initial clip matches the clip amount the JS computes at the very top/bottom of a tile's travel, so there is no jump on first paint.

GSAP effect (the important part — be exact)

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);

Lenis fires ScrollTrigger.update on every scroll; Lenis' own rAF is driven by GSAP's ticker; lag smoothing is disabled so the wave math tracks the scroll position tightly.

Config constants
const CONFIG = {
  waves: {
    base:   { amp: 0.1,   freq: 1.0, speed: 1.0, phase: 5.0  },
    flow:   { amp: 0.15,  freq: 5.0, speed: 5.0, phase: 10.0 },
    detail: { amp: 0.025, freq: 5.0, speed: 1.5, phase: 2.5  },
  },
  clipMax: 20,   // max clip inset in %
  clipPower: 2,  // exponent on the center-distance curve
};

const TOTAL_IMAGES = 12;
const IMAGE_BASE_HEIGHT = 375;                       // px
const ASPECT_RATIOS = ["3/2", "4/3", "5/4", "7/5"];  // cycled per tile
Building the 12 tiles (loop i = 0 … 11)

For each i, create <div class="spotlight-image"><img/></div> appended to .spotlight-images, and:

  • Aspect ratio: imageItem.style.aspectRatio = ASPECT_RATIOS[i % 4] — cycles 3/2, 4/3, 5/4, 7/5 down the column (all landscape-ish). Width therefore follows from the set height.
  • Progressive shrink of the last quarter:

``js const shrinkStartIndex = Math.floor(TOTAL_IMAGES * 0.75); // = 9 const shrinkFactor = i >= shrinkStartIndex ? (i - shrinkStartIndex + 1) / (TOTAL_IMAGES - shrinkStartIndex) // i=9→1/3, i=10→2/3, i=11→1 : 0; const imageHeight = IMAGE_BASE_HEIGHT * (1 - shrinkFactor * 0.5); imageItem.style.height = ${Math.round(imageHeight)}px; `` So tiles 0–8 are 375px tall; tiles 9, 10, 11 shrink to ~313px, 250px, ~188px — the column tapers toward the end.

  • Image source: one <img> per tile, src pointing at the i-th gallery image (img1.jpgimg12.jpg).
Responsive height pass — updateImageSizes()

Run once after building, and again on every resize:

const sizeFactor = Math.min(window.innerWidth / 750, 1);   // <750px → shrink; else 1
// for each tile, recompute with the same shrinkFactor logic:
const imageHeight = IMAGE_BASE_HEIGHT * sizeFactor * (1 - shrinkFactor * 0.5);
imageItem.style.height = `${Math.round(imageHeight)}px`;
Per-tile ScrollTrigger (the wave engine) — this is the whole point

Iterate every tile with its index. Compute normalizedIndex = index / (TOTAL_IMAGES - 1) (i.e. index / 11, ranging 0 at the top tile → 1 at the bottom tile). Create one ScrollTrigger per tile — no scrub, no pin; the motion is written imperatively inside onUpdate using ScrollTrigger's reported progress:

ScrollTrigger.create({
  trigger: imageItem,
  start: "top bottom",   // progress 0 = tile's top enters the viewport bottom
  end:   "bottom top",   // progress 1 = tile's bottom leaves the viewport top
  onUpdate: ({ progress }) => {
    const { base, flow, detail } = CONFIG.waves;
    const vw = window.innerWidth;

    // Three superimposed sine waves. Each argument mixes the tile's fixed
    // vertical position (normalizedIndex) with the live scroll progress:
    const baseWave =
      Math.sin(normalizedIndex * base.freq + (1 - progress) * base.speed + base.phase);
      // = sin(normalizedIndex*1.0 + (1 - progress)*1.0 + 5.0)   — note (1 - progress)

    const flowWave =
      0.5 + Math.sin(normalizedIndex * flow.freq + flow.phase + progress * flow.speed);
      // = 0.5 + sin(normalizedIndex*5.0 + 10.0 + progress*5.0)

    const detailWave =
      0.5 + Math.sin(normalizedIndex * detail.freq + detail.phase + progress * detail.speed);
      // = 0.5 + sin(normalizedIndex*5.0 + 2.5 + progress*1.5)

    // Horizontal position = center the tile, bias 10%vw left, then add the 3 waves,
    // each scaled by viewport width * its amplitude:
    const translateX =
        (vw - imageItem.offsetWidth) / 2          // horizontally center the tile
      - vw * 0.1                                  // constant 10%-of-viewport leftward bias
      + baseWave   * vw * base.amp                // slow swell   (± ~10% vw)
      + flowWave   * vw * flow.amp                // fast flow    ( 0…~30% vw, offset by 0.5)
      + detailWave * vw * detail.amp;             // fine detail  ( 0…~5%  vw, offset by 0.5)

    // Symmetric clip: fully clipped (20%) at the extremes of travel, fully open at center.
    const centerOffset = Math.abs(progress - 0.5) * 2;               // 1 → 0 → 1 across travel
    const clipAmount = Math.pow(centerOffset, CONFIG.clipPower) * CONFIG.clipMax; // 20 → 0 → 20 (%)

    imageItem.style.translate = `${translateX}px`;                    // CSS `translate` (X only)
    imageItem.style.clipPath  = `inset(0 ${clipAmount}% 0 ${clipAmount}%)`;
  },
});

Key behaviors to preserve exactly:

  • The three waves are additive and layered. base uses (1 - progress) (runs backward vs. scroll), while flow and detail use progress directly. flow and detail are lifted by +0.5 before the sine, so their contribution is always positive (biasing tiles rightward), whereas base swings symmetric about zero. flow is the dominant, most visible ripple (amp 0.15, high freq 5, high speed 5); base is the slow underlying sway; detail is a subtle high-frequency shiver.
  • Because the wave arguments include normalizedIndex, adjacent tiles are phase-shifted — the column reads as a continuous travelling wave down the page, not 12 identical bounces.
  • The clip is a symmetric horizontal inset driven by pow(|progress-0.5|*2, 2) * 20: a tile is a 20%-inset slit as it enters at the bottom (progress 0) and as it exits at the top (progress 1), and opens to inset(0 0% 0 0%) (full width) exactly at screen center (progress 0.5). The exponent 2 makes the opening snap open near center and stay narrow longer at the edges.
  • No ease/duration/stagger/timeline — this is not a tween. It is direct per-frame style writes from onUpdate, so the motion is 1:1 scrubbed to scroll position via Lenis.
Resize handler
window.addEventListener("resize", () => {
  updateImageSizes();
  ScrollTrigger.refresh();
});

Assets / images

12 gallery images, landscape-ish, filling their tiles with object-fit: cover (tile aspect ratios cycle 3/2, 4/3, 5/4, 7/5, so source them roughly landscape — exact crop doesn't matter). They read as one quiet, editorial still-life/nature set with an airy, warm-neutral feel: a mix of macro close-ups, abstract texture, an interior, and a product shot — some on dark backdrops, others high-key and pale. Colors range across warm ambers and browns, soft marble greys, deep burgundy, and pale beige/sage. Describe generically, by role. Verified examples from the actual set:

  1. Still-life macro — a silver spoon heaped with translucent amber-gold sugar crystals, lit warm against a near-black deep-blue backdrop.
  2. Abstract macro of flowing white-and-grey marble, glossy sculptural swells veined in charcoal (a soft, high-key texture plate).
  3. Two pale cream-white moths, wings spread, floating against a rich deep-burgundy velvet field.
  4. Warm macro of curled, dried autumn leaves — coppery orange-brown, backlit, on a dark shadowed background.
  5. Soft, faded interior — a tall paned window with climbing white roses and green vines, a distressed pale wall, and an ornate gilt-framed mirror; airy off-white and sage tones.
  6. Beauty product still-life — a frosted cream jar, a rose-pink lipstick in a rose-gold case, and a frosted serum bottle beside pale petals on cream silk; soft blush-beige palette.

The remaining tiles continue this restrained still-life/nature/texture mix (macro details, soft textures, and quiet interiors) in the same warm-neutral-to-dark range — no branded or fashion-catalog imagery. If you have fewer than 12, repeat them in order; the effect is identical regardless of content.

Behavior notes

  • Scroll is the sole driver. There is no autoplay and no pin/scrub timeline — each tile's own ScrollTrigger reports progress and the onUpdate writes translate + clip-path every frame. Scrolling up reverses the wave exactly.
  • The tall .spotlight section (height auto via height:100%) provides the runway; intro and outro stay at 100svh each. Keep overflow:hidden on sections so horizontal wave drift is clipped, not scrollable.
  • sizeFactor = min(innerWidth/750, 1) scales tile heights down on narrow screens; the wave math itself always uses live window.innerWidth, so amplitudes scale with the viewport. Effect is desktop-oriented but functions at all widths.
  • Keep will-change: transform, clip-path on tiles for smooth clip animation, and set the initial CSS clip-path: inset(0 20% 0 20%) so tiles paint pre-clipped (matching the progress = 0 state).
  • No reduced-motion branch in the original.