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 theoverflow: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-levelheight:100%overrides thesection'sheight: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 viatranslate)..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,srcpointing at the i-th gallery image (img1.jpg…img12.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.
baseuses(1 - progress)(runs backward vs. scroll), whileflowanddetailuseprogressdirectly.flowanddetailare lifted by+0.5before the sine, so their contribution is always positive (biasing tiles rightward), whereasbaseswings symmetric about zero.flowis the dominant, most visible ripple (amp 0.15, high freq 5, high speed 5);baseis the slow underlying sway;detailis 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
insetdriven bypow(|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 toinset(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:
- Still-life macro — a silver spoon heaped with translucent amber-gold sugar crystals, lit warm against a near-black deep-blue backdrop.
- Abstract macro of flowing white-and-grey marble, glossy sculptural swells veined in charcoal (a soft, high-key texture plate).
- Two pale cream-white moths, wings spread, floating against a rich deep-burgundy velvet field.
- Warm macro of curled, dried autumn leaves — coppery orange-brown, backlit, on a dark shadowed background.
- 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.
- 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
ScrollTriggerreportsprogressand theonUpdatewritestranslate+clip-pathevery frame. Scrolling up reverses the wave exactly. - The tall
.spotlightsection (height auto viaheight:100%) provides the runway; intro and outro stay at100svheach. Keepoverflow:hiddenon 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 livewindow.innerWidth, so amplitudes scale with the viewport. Effect is desktop-oriented but functions at all widths.- Keep
will-change: transform, clip-pathon tiles for smooth clip animation, and set the initial CSSclip-path: inset(0 20% 0 20%)so tiles paint pre-clipped (matching theprogress = 0state). - No reduced-motion branch in the original.