All components

TheFirstTheLast Scroll Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

A pinned section where six rotated product cards stack up one after another from the bottom of the viewport as you scroll, then fly off toward the top-left corner. A single ScrollTrigger (pin + scrub over 8x viewport height, Lenis-smoothed) drives an onUpdate handler that maps scroll progress to each card's y-entry and staggered x/y exit offset.

How it's built

Categoryscroll
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

scroll sticky-cards pin scrub stacking gsap lenis editorial product-cards

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

Pinned Stacking Cards — Scroll-Driven Deck That Deals Up & Flies Off

Goal

Build a single pinned section where six rotated product cards deal up one at a time from the bottom of the viewport as you scroll. Each card slides up to dead center; once the next card starts coming in, the previous cards drift off toward the top-left corner — earlier cards flying farther than later ones, so a fanned trail of tilted cards accumulates up-left while a fresh card keeps landing in the middle. The whole thing is driven by ONE pinned, scrubbed ScrollTrigger (spanning 8 viewport-heights of scroll, Lenis-smoothed) whose onUpdate maps scroll progress to every card's entry y and staggered exit x/y. A dark hero above and a dark outro below bookend the light-grey pinned stage.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis for smooth scroll. Register with gsap.registerPlugin(ScrollTrigger). Run everything inside a DOMContentLoaded handler.

Lenis wiring (standard GSAP integration):

const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);

Layout / HTML

Three stacked full-viewport sections. Class names are load-bearing — the JS/CSS query them.

<section class="hero">
  <h1>Future threads for a fractured world.</h1>
</section>

<section class="sticky-cards">
  <div class="card">
    <div class="card-img"><img src="..." alt="" /></div>
    <div class="card-content"><p>X01-842</p></div>
  </div>
  <!-- 5 more .card blocks, same structure, different label + image -->
</section>

<section class="outro">
  <h1>Tomorrow, tailored.</h1>
</section>
  • Exactly 6 .card elements inside .sticky-cards.
  • Card labels (the .card-content p text), in DOM order: X01-842, V9-372K, Z84-Q17, L56-904, A23-7P1, T98-462 — cryptic uppercase product/SKU codes.
  • Hero copy: "Future threads for a fractured world." Outro copy: "Tomorrow, tailored." No nav, no footer.

Styling

Fonts: body uses "PP Neue Montreal" (a clean grotesque sans — fall back to a neutral sans-serif if unavailable); the card labels use "Akkurat Mono" (a monospace — fall back to any monospace). Neither is a Google font; the exact face is not critical, only the sans / mono contrast.

Global:

  • * { margin:0; padding:0; box-sizing:border-box }.
  • body { font-family:"PP Neue Montreal", sans-serif }.
  • img { width:100%; height:100%; object-fit:cover }.
  • section { position:relative; width:100vw; height:100vh; overflow:hidden } — every section is exactly one viewport.

Sections:

  • .hero, .outro { display:flex; justify-content:center; align-items:center; text-align:center; padding:1em; background-color:#202020; color:#fff } — dark charcoal panels with centered white text.
  • .hero h1, .outro h1 { font-size:3rem; font-weight:400; line-height:1 }.
  • .sticky-cards { background-color:#e3e3e3 } — light grey stage (this is what stays pinned).

Cards — all six are stacked in the exact same centered spot:

  • .card { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); will-change:transform; width:25%; height:50%; padding:0.5em; display:flex; flex-direction:column; gap:0.5em; background-color:#202020; color:#fff } — a dark card, one quarter of viewport width and half its height, so a portrait tile. Keep will-change:transform.
  • .card-img { flex:1 1 0; min-height:0; width:100% } — the photo fills all but the label strip.
  • .card-content { flex:0 0 12px; display:flex; align-items:center } — a thin 12px label bar under the image.
  • .card-content p { text-transform:uppercase; font-family:"Akkurat Mono", monospace; font-size:12px }.
  • Media query max-width: 900px: .card { width:75% } (cards get much wider on small screens).

Note the CSS transform: translate(-50%,-50%) centering is immediately overwritten by GSAP's gsap.set in JS (which writes its own translate + rotate), so at runtime GSAP owns each card's transform.

GSAP effect (be exact)

Data
const cards = gsap.utils.toArray(".card");        // the 6 cards, DOM order
const rotations = [-12, 10, -5, 5, -5, -2];       // fixed tilt per card, degrees

The rotation list is per-card and set once, never re-animated — each card keeps its tilt the entire time, which is what makes the flown-off pile look scattered rather than uniform.

Initial state — park every card one viewport below, pre-tilted
cards.forEach((card, index) => {
  gsap.set(card, {
    y: window.innerHeight,   // pushed a full viewport-height below its centered home
    rotate: rotations[index],
  });
});

So before any scroll, all six cards sit off-screen just under the fold, each already rotated. Nothing is visible in .sticky-cards except the grey background.

The single ScrollTrigger (pinned, scrubbed, 8×viewport runway)
ScrollTrigger.create({
  trigger: ".sticky-cards",
  start: "top top",
  end: `+=${window.innerHeight * 8}px`,   // eight viewport-heights of scroll distance
  pin: true,
  pinSpacing: true,
  scrub: 1,                                // ~1s catch-up smoothing on progress
  onUpdate: (self) => { /* see below */ },
});

.sticky-cards pins in place for 8 viewport-heights of scrolling; scrub: 1 means the reported self.progress eases ~1 second behind the raw scroll position (gives the cards a smooth, slightly laggy feel and makes everything fully reversible).

onUpdate — map progress to each card's position

There are no tweens with real duration — every frame just writes positions via gsap.to(card, { ..., duration: 0, ease: "none" }) (an instant set; the smoothing comes from scrub, not from tween duration). Per frame:

const progress = self.progress;              // 0 → 1 across the whole pin
const totalCards = cards.length;             // 6
const progressPerCard = 1 / totalCards;      // ≈ 0.1667 — each card owns one slice

Then for each card at index:

1. Per-card entry window.

const cardStart = index * progressPerCard;                 // 0, 1/6, 2/6, …, 5/6
let cardProgress = (progress - cardStart) / progressPerCard;
cardProgress = Math.min(Math.max(cardProgress, 0), 1);     // clamp 0..1

So card 0 slides in over progress 0 → 1/6, card 1 over 1/6 → 2/6, … card 5 over 5/6 → 1. Cards deal in strictly one after another.

2. Entry Y (slide up from below to center).

let yPos = window.innerHeight * (1 - cardProgress);   // innerHeight → 0 as it enters
let xPos = 0;

While cardProgress runs 0→1 the card travels from y = innerHeight (off-screen bottom) up to y = 0 (its centered home). At the top of its stack it sits dead center, tilted by its rotations[index].

3. Exit toward top-left (only for already-landed, non-last cards).

if (cardProgress === 1 && index < totalCards - 1) {
  const remainingProgress =
    (progress - (cardStart + progressPerCard)) /
    (1 - (cardStart + progressPerCard));
  if (remainingProgress > 0) {
    const distanceMultiplier = 1 - index * 0.15;
    xPos = -window.innerWidth  * 0.3 * distanceMultiplier * remainingProgress;
    yPos = -window.innerHeight * 0.3 * distanceMultiplier * remainingProgress;
  }
}

Key details:

  • A card only begins leaving after it has fully landed (cardProgress === 1, i.e. progress passed (index+1)/6).
  • The last card (index 5) never leavesindex < totalCards - 1 excludes it, so it stays centered as the final resting card.
  • remainingProgress = how far through *the rest of the scroll* (from the moment this card finished entering, (index+1)/6, to the very end 1) we currently are, 0 → 1.
  • distanceMultiplier = 1 - index*0.15 → card0 1.0, card1 0.85, card2 0.70, card3 0.55, card4 0.40. Earlier cards fly farther, so they end up higher and more to the left — a fanned diagonal trail toward the top-left corner.
  • Both x and y go negative and scale together, so each departing card slides up-and-left. Max reach for card0 is x ≈ -30% viewport width, y ≈ -30% viewport height; later cards drift progressively less.
  • Because rotation is never touched here, each card keeps its original tilt as it flies off.

4. Commit the frame.

gsap.to(card, { y: yPos, x: xPos, duration: 0, ease: "none" });
Net choreography

As you scroll through the 8-viewport pin: card X01-842 rises to center, then as V9-372K starts rising, X01-842 peels off up-left; V9-372K lands, then peels off up-left (a bit less far) as Z84-Q17 rises; and so on. By the end, five tilted cards are scattered across the upper-left quadrant (earlier = farther) and the sixth, T98-462, rests centered. Scrolling back up reverses the entire sequence exactly.

Assets / images

6 portrait editorial fashion photographs, one per card. Each fills a .card-img slot that is ~25vw wide × ~50vh tall minus a 0.5em pad and a 12px label bar — so effectively a tall portrait crop (~3:4 to ~4:5), object-fit: cover. Treat them as a matched avant-garde apparel lookbook: single garments / styled figures on plain studio or muted editorial backdrops, moody neutral palette so the images sit well on dark cards against the light-grey stage. Six distinct shots (no repeats).

Behavior notes

  • Entirely scroll-driven and reversible — nothing autoplays; the hero and outro are static text panels, the cards only move with scroll (smoothed by Lenis + scrub: 1).
  • The pin hijacks scroll for 8 viewport-heights: after the hero, the grey .sticky-cards section locks in place while the whole deck deals through, then releases into the outro. pinSpacing: true reserves that scroll distance in the document flow.
  • window.innerHeight / window.innerWidth are read at setup time for the initial park, the pin length, and the exit offsets; a page reload picks up the current viewport size (no resize handler in the original).
  • Responsive: the only breakpoint is max-width: 900px, where cards widen to 75% — the progress-based math is all relative to the viewport, so the effect itself works at any size.
  • Keep will-change: transform on .card — six simultaneously transformed cards are the heaviest part.