All components

Sticky Stacking Cards on Scroll

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

Open live demo ↗ Raw prompt (.md)

What it does

Full-width service cards that pin at 35% of the viewport on scroll and stack under each incoming card: every pinned card's inner wrapper is scrubbed upward by a decreasing multiple of 14vh, producing a layered peel effect, while the intro headline stays pinned (no pin spacing) between the first and last card. Driven by GSAP ScrollTrigger with Lenis smooth scrolling.

How it's built

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

sticky stacked-cards scroll pin scrub lenis

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 Stacking Service Cards on Scroll

Goal

Build a scroll-driven page where four full-width service cards each pin at 35% of the viewport as you scroll and stack up under the next incoming card, while every pinned card's inner wrapper is scrubbed upward by a decreasing multiple of 14vh — producing a layered "peel" as later cards slide over earlier ones. A centered intro headline stays pinned (with no pin spacing) for the whole duration of the stack, from the first card until the last. Smooth scroll via Lenis. The star effect is the per-card pin + scrubbed inner-translate stack, with the headline pin overlapping it.

Tech

Vanilla HTML/CSS/JS with ES module imports, built under Vite (npm). Use:

  • gsap (npm) with the single plugin ScrollTrigger (gsap/ScrollTrigger), registered once via gsap.registerPlugin(ScrollTrigger).
  • lenis (npm) for smooth scroll, wired into GSAP's ticker.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);

No framework, no SplitText, no CustomEase, no Three.js. All logic runs at module top level (no DOMContentLoaded wrapper needed since the script is a deferred module).

Layout / HTML

One wrapper div.app containing five siblings in this exact order:

<div class="app">
  <section class="hero"><img src="…hero" alt="" /></section>

  <section class="intro">
    <h1>Creating standout brands for startups that bring joy and leave lasting impressions.</h1>
  </section>

  <section class="cards">
    <div class="card" id="card-1">
      <div class="card-inner">
        <div class="card-content">
          <h1>Brand Foundation</h1>
          <p>The heart of your company's story. It shapes your vision, values, and voice, ensuring a clear and powerful impact in every, interaction.</p>
        </div>
        <div class="card-img"><img src="…card-1" alt="Brand Foundation" /></div>
      </div>
    </div>
    <div class="card" id="card-2"> … Design Identity … </div>
    <div class="card" id="card-3"> … Digital Presence … </div>
    <div class="card" id="card-4"> … Product Design … </div>
  </section>

  <section class="outro"><h1>Let's build a brand that leaves a mark.</h1></section>
</div>
  • Four cards, ids card-1card-4. Each .card wraps a .card-inner that holds two children: a .card-content (an <h1> title + a <p> paragraph) and a .card-img (a single <img>).
  • Card titles / paragraphs (neutral agency copy):
  • Brand Foundation — "The heart of your company's story. It shapes your vision, values, and voice, ensuring a clear and powerful impact in every, interaction."
  • Design Identity — "Your brand's visual fingerprint. It crafts a distinctive look that sparks recognition and builds emotional connections with your audience."
  • Digital Presence — "Our web solutions combine cutting-edge design and seamless functionality to create experiences that captivate and inspire your audience."
  • Product Design — "We craft user-first products that are both functional and visually appealing, delivering solutions that leave a lasting impression."
  • Intro <h1>: "Creating standout brands for startups that bring joy and leave lasting impressions."
  • Outro <h1>: "Let's build a brand that leaves a mark."

Styling

Fonts: load Geist and Geist Mono from Google Fonts (family=Geist:wght@100..900&family=Geist+Mono:wght@100..900). Use Geist as the page sans-serif (the body font-family). No monospace is actually rendered; Geist is what shows.

Global reset & base:

  • * { margin:0; padding:0; box-sizing:border-box; }
  • img { width:100%; height:100%; object-fit:cover; }
  • h1 { font-size:4rem; font-weight:600; line-height:1; margin-bottom:2.5em; }
  • p { font-size:1.25rem; font-weight:500; }

Full-viewport panels:

  • .hero, .intro, .outro { position:relative; width:100vw; height:100vh; padding:2em; }
  • .hero { padding:0; } (image is full-bleed).
  • .intro, .outro { background-color:#fff; display:flex; align-items:center; }
  • .intro h1, .outro h1 { margin-bottom:0; }

Cards (this is the geometry the effect hangs on):

  • .card { position:relative; }no explicit height; each card's height is content-driven.
  • .card-inner { position:relative; will-change:transform; width:100%; height:100%; padding:2em; display:flex; gap:4em; } — the .card-inner is the element that gets translated by GSAP (not the .card), and will-change:transform is set on it.
  • .card-content { flex:3; } (text column, ~3/4 width)
  • .card-img { flex:1; aspect-ratio:16/9; border-radius:0.75em; overflow:hidden; } (image column, ~1/4 width, rounded, clipped)

Per-card .card-inner background colors (the color lives on .card-inner, not .card):

  • #card-1 .card-inner { background-color:#c3abff; } (lilac / light purple)
  • #card-2 .card-inner { background-color:#ffffff; } (white)
  • #card-3 .card-inner { background-color:#fed35b; } (warm yellow)
  • #card-4 .card-inner { background-color:#1e1e1e; color:#fff; } (near-black, white text)

Because cards are later in DOM order, each incoming card paints on top of the previous ones — so as cards pin and stack, card-4 ends visually on top.

GSAP effect (be exhaustive)

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

Default Lenis options. Lenis scroll events drive ScrollTrigger.update, GSAP's ticker drives Lenis' raf, and lag smoothing is disabled so scrub stays glued to scroll.

Collect cards
const cards = gsap.utils.toArray(".card"); // [card-1, card-2, card-3, card-4], length 4
1) Pin the intro headline across the whole stack (no pin spacing)

A single ScrollTrigger pins the .intro section in place while all the cards scroll and stack:

ScrollTrigger.create({
  trigger: cards[0],               // card-1
  start: "top 35%",                // pin begins when card-1's top reaches 35% of viewport
  endTrigger: cards[cards.length - 1], // card-4 (last card)
  end: "top 30%",                  // pin releases when card-4's top reaches 30% of viewport
  pin: ".intro",
  pinSpacing: false,               // NO spacer — following content keeps scrolling under the fixed headline
});

The intro headline (which sits directly above the cards in the DOM) freezes in place the moment card-1 climbs to the 35% line and stays frozen until card-4 nearly arrives (its top at 30%). With pinSpacing:false, no extra scroll length is inserted — the cards scroll up over/under the pinned headline.

2) Per-card pin + scrubbed inner-translate (the stack)

Loop every card. Skip the last card (index === cards.length - 1) — card-4 is never pinned and never animated; it is the final card that lands on top of the stack. For each of the first three cards (card-1, card-2, card-3):

a) Pin the card (no pin spacing):

ScrollTrigger.create({
  trigger: card,
  start: "top 35%",     // this card pins when its top reaches 35% of viewport
  endTrigger: ".outro",
  end: "top 65%",       // stays pinned until the outro's top reaches 65% of viewport
  pin: true,
  pinSpacing: false,    // no spacer → each card pins at the same 35% line and overlaps the previous pinned cards
});

Because every card pins at the identical top 35% line with pinSpacing:false, they accumulate stacked at that line — a new card slides up and pins over the ones already held, building the deck.

b) Scrub the card's inner wrapper upward:

gsap.to(card.querySelector(".card-inner"), {
  y: `-${(cards.length - index) * 14}vh`, // negative → moves UP, in vh units
  ease: "none",                            // linear; only easing comes from scrub + Lenis
  scrollTrigger: {
    trigger: card,
    start: "top 35%",     // same start as the pin
    endTrigger: ".outro",
    end: "top 65%",       // same end as the pin — all inners finish together at outro top 65%
    scrub: true,          // tied 1:1 to scroll position
  },
});

Exact y end values ((cards.length - index) * 14vh, with cards.length === 4):

  • card-1 (index 0): y: -56vh
  • card-2 (index 1): y: -42vh
  • card-3 (index 2): y: -28vh
  • card-4 (index 3): no tween (last card)

Each .card-inner slides from y: 0 to its negative target over its own scrub window. Earlier cards travel farther up (−56 → −42 → −28) and, because they reach the 35% line sooner but all share the same end (outro top 65%), they animate over a longer scroll span. The net read: as each incoming card pins over the one below it, the covered card's content drifts upward and peeks past the top edge of the newcomer — a continuous layered peel, with the dark card-4 settling on top at the end.

Values summary
  • Trigger: scroll only (ScrollTrigger + Lenis). No load/hover/click/mousemove.
  • Animated property: y (translateY) of each .card-inner, in vh. Start 0 → end −56vh / −42vh / −28vh for cards 1/2/3.
  • ease: "none" (linear) on the tweens. scrub: true on the inner-translate triggers.
  • Pins: intro pinned card-1 top 35%card-4 top 30%; cards 1–3 each pinned top 35%outro top 65%. All pins use pinSpacing:false.
  • No duration/delay/stagger/timeline labels, no SplitText, no CustomEase, no lerp/rAF loop of your own (Lenis' internal rAF is the only one). No gsap.set initial poses.

Assets / images

  • 1 hero image — a full-viewport, full-bleed background filling .hero (width:100%; height:100%; object-fit:cover). A landscape (~3:2) black-and-white studio photograph: a lone folding chair spotlit at center on a seamless cyclorama backdrop, deep-black surrounds and soft grey floor — a moody, high-contrast greyscale editorial scene.
  • 4 card images — one per card, each rendered inside a 16:9 rounded, overflow-hidden frame occupying roughly the right quarter of the card (.card-img, flex:1, object-fit:cover; portrait/square sources are center-cropped to the frame). Four cohesive editorial / product photographs, provided in card order (repeat in order if fewer are available). No brand logos:
  • Lilac card — a landscape (16:9) monochrome purple flat-lay of streetwear laid out on a deep-violet surface (sneakers, folded white tee, shoulder bag, cap, jacket); dominant tones lilac and purple with white accents.
  • White card — a portrait product photograph of a single cream-and-terracotta sneaker floating among red and white flowers; warm blush background, dominant colors cream, deep red and soft pink.
  • Yellow card — a grainy black-and-white portrait crop: the collar and neck of a person in a dark blazer against a pale grey wall, face out of frame; high-contrast greyscale.
  • Dark card — a light grey-and-white marble / fluid texture, soft flowing swirls with faint darker veining; near-monochrome white-and-grey abstract.

Behavior notes

  • Desktop-first. At max-width:900px: h1 margin-bottom becomes 4rem; p font-size becomes 1rem; .card-inner switches to flex-direction:column; and .card-img { display:none; } — the image column is hidden entirely on small screens, so cards show text only. The pin/scrub stack still runs.
  • Everything is scroll-scrubbed — nothing autoplays, so motion only occurs while scrolling. No explicit reduced-motion handling in the original.
  • Section panels use 100vh; cards are content-height. The whole effect relies on pinSpacing:false everywhere so the pinned elements overlap instead of pushing the page longer.