Sticky Stacked Cards Scroll Reveal (GSAP + Lenis)
Goal
Build a scroll-driven "sticky stacked cards" section: five full-size image cards sit stacked in a centered rounded container inside a pinned section. As the user scrolls, each front card shrinks and rotates away while its image zooms in, and the next card slides up from below to cover it — one card swap per viewport-height of scroll, fully scrubbed to the scrollbar.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis (npm) for smooth scrolling. Register ScrollTrigger with gsap.registerPlugin(ScrollTrigger). Run everything inside a DOMContentLoaded listener.
Layout / HTML
Three full-viewport <section> elements, in order:
<section class="intro">— a single<h1>with a long editorial sentence about art and motion, e.g. "Art is not what you see. It's what you *feel* in the blur, the chaos, the motion — every pulse captured in color and form."<section class="sticky-cards">— contains one<div class="cards-container">holding exactly 5<div class="card">elements. Each card contains:<div class="tag"><p>LABEL</p></div>— short uppercase labels, one per card, in this order: "Raw Emotion", "Inner Conflict", "Fury & Flow", "Rebellion", "Liberation".<img src="...">— one image per card (see Assets).<section class="outro">— another<h1>with a closing sentence, e.g. "This isn't just motion. It's meaning in movement. In every blurred edge and amplified hue, we trace the shape of something deeper — truth in abstraction."
Load styles.css via <link> and the script via <script type="module" src="./script.js"> at the end of <body>.
Styling
- Global reset:
* { margin: 0; padding: 0; box-sizing: border-box; }. - Fonts (Google Fonts
@importin the CSS): body uses "DM Sans"; tag labels use "IBM Plex Mono". img:position: relative; width: 100%; height: 100%; object-fit: cover;.h1:font-size: 5vw; font-weight: 500; line-height: 1; letter-spacing: -0.02em; text-indent: 5em; color: #e3e3db;with antialiased font smoothing.- Every
section:position: relative; width: 100vw; height: 100svh; padding: 2em; background-color: #1f1f1f; overflow: hidden;. .intro,.outro: flex, centered both axes..sticky-cards: flex, centered both axes,background-color: #0f0f0f; color: #fff;(darker than the other sections)..cards-container:position: relative; width: 50%; height: 50%; border-radius: 0.5em; overflow: hidden;. Theoverflow: hiddenhere is essential — it clips the incoming card that waits below aty: 100%..card:position: absolute; width: 100%; height: 100%; border-radius: 0.5em; overflow: hidden;— all 5 cards occupy the exact same box, stacked by DOM order (later cards naturally paint on top once they slide in)..tag:position: absolute; top: 1em; left: 1em; padding: 0.5em; border-radius: 0.25em; background: #000; z-index: 1;..tag p:text-transform: uppercase; font-family: "IBM Plex Mono"; font-size: 12px; font-weight: 600; line-height: 1;.- Media query
max-width: 1000px:h1 { font-size: 7vw; text-indent: 2em; }and.cards-container { width: 95%; }.
GSAP effect (be exact)
Lenis smooth scroll wiring
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
Initial state (gsap.set)
- Card index 0 (front card):
{ y: "0%", scale: 1, rotation: 0 }; itsimg:{ scale: 1 }. - Every other card (indices 1–4):
{ y: "100%", scale: 1, rotation: 0 }(parked one full container-height below, hidden by the container'soverflow: hidden); each of theirimgs:{ scale: 1 }.
Pinned, scrubbed timeline
One gsap.timeline whose ScrollTrigger is:
trigger: ".sticky-cards"start: "top top"end: "+=" + window.innerHeight * (totalCards - 1)— i.e.+=4 × window.innerHeightfor 5 cards.pin: true(default pinSpacing, so the page gains 4 extra viewport heights of scroll)scrub: 0.5
Timeline content
Loop for (let i = 0; i < totalCards - 1; i++) — 4 transitions. For each i, add three tweens at the same absolute timeline position i (position parameter = the integer i, so transition 0 occupies timeline time 0→1, transition 1 occupies 1→2, etc.). All tweens use duration: 1 and ease: "none" (linear, since the motion is scrubbed):
- Current card (
cards[i]) →{ scale: 0.5, rotation: 10, duration: 1, ease: "none" }— it shrinks to half size and tilts 10 degrees clockwise while still visible behind the incoming card. - Current card's image (
images[i]) →{ scale: 1.5, duration: 1, ease: "none" }— the photo zooms inside the shrinking card, a counter-zoom parallax. - Next card (
cards[i + 1]) →{ y: "0%", duration: 1, ease: "none" }— it slides up from100%to fully cover the container, wiping over the shrinking card beneath it.
All three motions are perfectly simultaneous per transition. Because positions are integers and durations are 1, the four transitions run back-to-back with no gaps or overlaps, and each one maps to exactly one viewport-height of scroll. The last card (index 4) never shrinks — it stays full size when the pin releases.
Assets / images
5 images, one per card, filling the card completely (object-fit: cover; the container is a wide landscape box — roughly 50vw × 50svh). Use abstract expressionist / painterly artworks with blurred, energetic brushstrokes matching each tag's mood:
- Warm ochre/red abstract painting with sweeping blurred strokes (Raw Emotion).
- Swirling smeared color, chaotic motion blur (Inner Conflict).
- High-energy streaked strokes with amplified hues (Fury & Flow).
- Bold gestural marks, rebellious blurred motion (Rebellion).
- Lighter, freer flowing brushwork suggesting release (Liberation).
Behavior notes
- The entire effect is scrub-driven: no autonomous animation on load; scrolling backwards reverses everything.
- Section is pinned for 4 extra viewport heights; intro and outro scroll normally before/after.
- Works at any viewport size; below 1000px the container widens to 95% and headings scale up.