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.

Images

This component ships with 6 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/thefirstthelast-scroll-animation/card-1.jpeg
https://motionprompts.dev/c/thefirstthelast-scroll-animation/card-2.jpeg
https://motionprompts.dev/c/thefirstthelast-scroll-animation/card-3.jpeg
https://motionprompts.dev/c/thefirstthelast-scroll-animation/card-4.jpeg
https://motionprompts.dev/c/thefirstthelast-scroll-animation/card-5.jpeg
https://motionprompts.dev/c/thefirstthelast-scroll-animation/card-6.jpeg

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--ink, --graphite, --paper, --white, --mute, --line. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the lenis.on("scroll", ScrollTrigger.update) wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.

Adapting this to React

Everything above describes a standalone document: a script that waits for DOMContentLoaded, then wires up Lenis, GSAP, and one pinned ScrollTrigger that never has to undo itself. React withdraws all three guarantees at once, and quietly — the deck renders, looks right for a moment, then misbehaves in a way that doesn't point back at any of this. Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Setup that runs twice with teardown that runs never leaves you two of everything here specifically: two ScrollTriggers pinning the same .sticky-cards section and disagreeing about the same scrub, plus two Lenis instances both feeding ScrollTrigger.update. The visible symptom is the six cards jittering or racing ahead of the actual scroll position, and it will not reproduce in a production build, because React only double-mounts in development. Treat the cleanup as part of the effect, not an afterthought.

(1) The entry point. The script waits for DOMContentLoaded. By the time a React component mounts, that event has already fired, so the listener is never called and the whole deck stays parked below the fold — no error, nothing to debug. Delete the listener and move its body — gsap.registerPlugin, the Lenis instantiation, the ticker wiring, the initial gsap.set pass over the six cards, and the ScrollTrigger.create call — into a useEffect with an empty dependency array. Keep gsap.registerPlugin(ScrollTrigger) at module scope rather than inside the effect; re-registering on every mount is harmless but pointless.

(2) Element lookups. gsap.utils.toArray(".card") and the ScrollTrigger's trigger: ".sticky-cards" both assume this component owns the document. Give the component a root ref that wraps .hero, .sticky-cards, and .outro, and scope both lookups to it — but they don't scope the same way. A selector string passed as trigger inside a gsap.context tied to that root resolves automatically, because ScrollTrigger.create is one of the calls the context rewrites for you. gsap.utils.toArray is a plain utility, not a tween or a trigger, and gets no such treatment — pass the root as its second argument, gsap.utils.toArray(".card", rootRef.current), or during the StrictMode remount the six cards it collects can come from the copy of the subtree that's on its way out, and the whole per-card progress math in onUpdate ends up animating detached nodes.

(3) Cleanup. Wrap the gsap.set pass and the ScrollTrigger.create call in a gsap.context scoped to the root ref, and revert that context in the cleanup:

useEffect(() => {
  const ctx = gsap.context(() => {
    const cards = gsap.utils.toArray(".card", rootRef.current);
    cards.forEach((card, index) => {
      /* the parked gsap.set pass, unchanged */
    });
    ScrollTrigger.create({
      trigger: ".sticky-cards",
      /* start, end, pin, pinSpacing, scrub, onUpdate — all as above */
    });
  }, rootRef);

  return () => ctx.revert();
}, []);

ctx.revert() undoes the pinned ScrollTrigger and the inline transforms every gsap.set/gsap.to call in onUpdate wrote onto the six cards — that covers everything created during the factory's synchronous pass. It does not touch the Lenis instance or the ticker subscription that drives it, because neither is a tween or a trigger; the context has no record of them.

Lenis is the document-level resource here — this component owns one instance for the life of its mount, so create it inside the same effect and destroy it in the same cleanup. The subtler failure is the ticker: gsap.ticker.add((time) => { lenis.raf(time * 1000); }) is a standing subscription that ctx.revert() does not touch, so a reverted context sitting next to an undestroyed Lenis instance looks like a complete teardown while the ticker keeps calling .raf() on an instance nothing is animating into anymore. Keep the callback in a named variable so the cleanup removes the exact same function reference:

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

  const ctx = gsap.context(() => {
    /* toArray + gsap.set pass + ScrollTrigger.create, as above */
  }, rootRef);

  return () => {
    ctx.revert();
    gsap.ticker.remove(driveLenis);
    lenis.destroy();
  };
}, []);

lenis.destroy() also releases the scroll listener registered via .on(), so there's nothing extra to unhook there. If a StrictMode remount leaves the previous mount's driveLenis registered, the new mount adds a second one on top of it — two live Lenis instances both call .raf() every tick and both push ScrollTrigger.update, so the progress value the pinned deck's onUpdate slices into six equal windows gets refreshed twice as often, and the cards visibly race ahead of where the actual scroll position says they should be.