All components

Sticky Cards ScrollTrigger

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

Open live demo ↗ Raw prompt (.md)

What it does

A stack of full-screen sections is pinned one after another with ScrollTrigger (pin, pinSpacing:false, scrub) so each card holds in place while the next scrolls over it, and each pinned image scales from 1 down to 0.5 as the following section approaches. The hero heading fades its opacity from 1 to 0 across the first 400vh of scroll.

How it's built

Categoryscroll
Techgsap
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes

sticky sticky-cards scroll pin scrub scale scrolltrigger gallery

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

Sticky Cards ScrollTrigger — Pinned Full-Screen Deck That Shrinks Each Image

Goal

Build a full-screen, scroll-driven image gallery. A hero heading and a stack of full-viewport image cards are each pinned in place one after another with ScrollTrigger (pin, pinSpacing:false, scrub), so every card holds fixed while the next section scrolls up and covers it. As a card gets covered, its centered image scales down from 1 to 0.5 (a fixed 1000×700 frame shrinking to half). The giant hero headline fades its opacity from 1 to 0 across the first 400vh of scroll. The last card scrolls normally (not pinned), then a footer releases the whole pinned stack. The signature effect is the layered "sticky cards" stack with each image quietly shrinking as it's buried.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the single GSAP plugin ScrollTrigger. No other plugins, no Lenis, no framework — plain Vite-style module imports:

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

Register once: gsap.registerPlugin(ScrollTrigger);. Wrap all setup in a DOMContentLoaded listener. Native scroll (no smooth-scroll library).

Layout / HTML

A .container wraps a fixed logo, a hero section, six card sections, and a footer. Class names are load-bearing — the JS queries .pinned, .card.scroll, .img, .footer, and .hero h1.

<div class="container">
  <div class="logo"><a href="#">Flow Canvas</a></div>

  <section class="hero pinned">
    <h1>Sculpted Zen <br />Playground</h1>
  </section>

  <section class="card pinned"><div class="img"><img src="…img1" alt="" /></div></section>
  <section class="card pinned"><div class="img"><img src="…img2" alt="" /></div></section>
  <section class="card pinned"><div class="img"><img src="…img3" alt="" /></div></section>
  <section class="card pinned"><div class="img"><img src="…img4" alt="" /></div></section>
  <section class="card pinned"><div class="img"><img src="…img5" alt="" /></div></section>

  <section class="card scroll"><div class="img"><img src="…img6" alt="" /></div></section>

  <section class="footer"><h1>Footer</h1></section>
</div>

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

Structure notes — these are what the effect hangs on:

  • .pinned matches 6 elements: the hero (class="hero pinned") plus the five image cards (class="card pinned"). The hero is treated as a pinned section too, even though it has no .img child (only an <h1>).
  • The sixth/last card is class="card scroll" (NOT .pinned) — it scrolls normally and is never pinned or scaled.
  • Every image card is section.card > div.img > img. The .img div is the animated element (the image scales via its wrapper).
  • Copy is fictional demo text: logo "Flow Canvas", hero "Sculpted Zen / Playground" (two lines via <br>), footer "Footer".

Styling

Global reset: * { margin:0; padding:0; box-sizing:border-box; }.

Palette & type:

  • Page background #000 (pure black); all text #fff.
  • Font family: a neutral sans-serif at weight 400 for everything (original references a proprietary face named "PP Acma" — any clean grotesque/sans is fine).
html, body { width:100%; height:100%; font-family:"PP Acma", sans-serif; background:#000; }
img { width:100%; height:100%; object-fit:cover; }
.container { width:100%; height:100%; }

section { width:100vw; height:100vh; }   /* every section is a full viewport */
  • Logo — fixed banner, top-center, above everything:

``css .logo { position:absolute; top:0; left:50%; transform:translateX(-50%); padding:2em; z-index:2; } .logo a { text-decoration:none; font-size:24px; font-weight:400; color:#fff; } ``

  • Hero heading — huge centered display type:

``css .hero h1 { position:absolute; width:100%; top:50%; left:50%; transform:translate(-50%,-50%); text-align:center; font-weight:400; font-size:200px; color:#fff; letter-spacing:-8px; line-height:90%; } ``

  • The image frame — a fixed-size box centered in each card, centered with a 3D-safe transform (important, GSAP scales it):

``css .img { position:absolute; top:50%; left:50%; transform:translate3d(-50%,-50%,0); width:1000px; height:700px; /* ~10:7 landscape, object-fit:cover */ } ``

  • Last (scroll) card gets position:relative;.card.scroll { position:relative; }. (The pinned cards keep the default static position; only the scrolling card is made relative.)
  • Footer — half-height, flex-centered:

``css .footer { width:100%; height:50vh; display:flex; justify-content:center; align-items:center; color:#fff; } .footer h1 { font-size:48px; font-weight:400; letter-spacing:-2px; } ``

The document is naturally tall: 7 sections at 100vh (hero + 6 cards) + a 50vh footer ≈ 750vh of native scroll. pinSpacing:false (below) means the pins do not add extra scroll length.

GSAP effect (be exhaustive — this is the core)

Everything runs inside document.addEventListener("DOMContentLoaded", …) after gsap.registerPlugin(ScrollTrigger).

References
const footer         = document.querySelector(".footer");
const lastCard       = document.querySelector(".card.scroll");
const pinnedSections = gsap.utils.toArray(".pinned");   // 6: hero + 5 image cards
Per-pinned-section loop — TWO ScrollTriggers per section

pinnedSections.forEach((section, index, sections) => { … }). For each of the 6 pinned sections:

let img = section.querySelector(".img");   // hero → null (GSAP tolerates a null target, no-op)

let nextSection   = sections[index + 1] || lastCard;                       // the section that will cover this one
let endScalePoint = `top+=${nextSection.offsetTop - section.offsetTop} top`; // = one viewport height of scroll

(A) The pin trigger — a target-less gsap.to whose only job is to attach a pin (it animates no properties):

gsap.to(section, {
  scrollTrigger: {
    trigger: section,
    start: "top top",
    end: index === sections.length
      ? `+=${lastCard.offsetHeight / 2}`                 // dead branch — index never equals length
      : footer.offsetTop - window.innerHeight,           // effective end for ALL pinned sections
    pin: true,
    pinSpacing: false,
    scrub: 1,
  },
});
  • start:"top top" — the section pins the moment its top reaches the top of the viewport.
  • end is a bare number (footer.offsetTop - window.innerHeight, ≈ 600vh in px). A numeric ScrollTrigger end is an absolute page scroll position, not relative — so every pinned section releases at that same scroll point (when the footer's top reaches the bottom of the viewport). Hero pins from scroll 0→600vh; card 1 from 100vh→600vh; … card 5 from 500vh→600vh. (The ternary's true branch is unreachable — index maxes at sections.length - 1 — so all six use the absolute end.)
  • pinSpacing:false is essential: no spacer is inserted, so the next section scrolls up over the pinned one instead of pushing content down. This is what makes the cards physically stack/overlap. Later DOM = painted on top, so each new card covers the previous.
  • scrub:1 → ~1s of smoothing catch-up between scroll position and the pin.

(B) The image-scale trigger — a separate scrubbed fromTo shrinking the .img wrapper:

gsap.fromTo(
  img,
  { scale: 1 },
  {
    scale: 0.5,
    ease: "none",
    scrollTrigger: {
      trigger: section,
      start: "top top",
      end: endScalePoint,     // "top+=<Δ> top" where Δ = distance to next section ≈ 100vh
      scrub: 1,
    },
  }
);
  • Animates scale: 1 → 0.5 on the .img box (linear, ease:"none"), scrubbed over exactly the scroll span during which the next section rises up to cover this one (endScalePoint = the pixel gap to the next section's top = one viewport height).
  • Because .img is centered with translate3d(-50%,-50%,0), the scale collapses toward the card's center. The 1000×700 frame becomes 500×350 by the time it's fully buried, then stays frozen at 0.5 while the section remains pinned (pin lasts to 600vh, far past the scale's 100vh span).
  • The hero section's img is null, so its scale tween is a harmless no-op — the hero only fades (below). The last card (.card.scroll) is not in .pinned, so it never scales and never pins; it scrolls past at full size.
Hero heading opacity fade (independent trigger)
const heroH1 = document.querySelector(".hero h1");
ScrollTrigger.create({
  trigger: document.body,
  start: "top top",
  end: "+=400vh",
  scrub: 1,
  onUpdate: (self) => {
    heroH1.style.opacity = 1 - self.progress;   // 1 → 0 across the first 400vh of scroll
  },
});
  • A body-level ScrollTrigger spanning the first 400vh of scroll. Its onUpdate writes the hero <h1>'s inline opacity directly (no tween) as 1 - progress, so the headline linearly fades from fully visible to invisible while the first cards begin stacking over the hero. scrub:1 smooths it.
Net choreography (top → bottom scroll)
  1. Hero is pinned; its 200px headline fades 1→0 over the first 400vh while card 1 scrolls up to cover it.
  2. Each image card, as it reaches the top, pins; the previous card's image has already shrunk toward 0.5 as this new card rose over it. The stack keeps building — up to 6 sections pinned simultaneously, each frozen at its final scale.
  3. The 6th card (.card.scroll) is NOT pinned — it scrolls normally over the pinned stack at full 1000×700 size.
  4. When the footer's top reaches the viewport bottom (scroll ≈ 600vh) all pins release together, and the footer ("Footer", 50vh) scrolls into view.
  5. All motion is linear and scrubbed — the only smoothing is scrub:1. No timelines, labels, stagger, delay, SplitText, CustomEase, lerp/rAF loops, or Three.js. Just per-section pin + scale 1→0.5, plus the hero opacity fade.

Assets / images

6 images, one per card, each shown inside the fixed 1000×700 px (~10:7 landscape) .img frame with object-fit:cover. Use a cohesive, minimal/editorial set — the effect reads best with quiet, well-composed images (e.g. full-bleed editorial street/fashion portraits and soft abstract color-gradient/texture fields). No brands, logos, or text in the images.

  • img1img5 → the five pinned cards (these are the ones that scale down as they're covered).
  • img6 → the final scrolling card (stays full size).

If fewer source images are available, repeat them in order; if more, use the first six.

Behavior notes

  • Desktop-first, native scroll. No Lenis/smooth-scroll and no reduced-motion branch in the original — nothing autoplays; every motion is scroll-scrubbed, so it only moves as the user scrolls.
  • Full-viewport sections. Keep each section at width:100vw; height:100vh (footer 50vh). The tall document (~750vh) plus pinSpacing:false supplies the scroll distance; do not add extra spacers.
  • pinSpacing:false + numeric absolute end are the crux — together they make all pinned sections release at one shared scroll position and let later cards overlap earlier ones. Changing either breaks the stacking look.
  • The hero has no image, so its scale tween targets null — this is intentional/expected (GSAP just skips it); do not add an .img to the hero.
  • Values are computed once at load from offsetTop / window.innerHeight; the original does not recompute on resize.