All components

BrandAppart Sticky Cards

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

Open live demo ↗ Raw prompt (.md)

What it does

Scroll-pinned deck of four stacked cards: as you scroll through the pinned section (8 viewport heights, scrub-synced with Lenis), the front card flies up while tilting back 35deg in 3D perspective, and the cards behind slide forward and scale up to take its place.

How it's built

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

sticky stacked-cards scroll pin scrub 3d-tilt 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

Scroll-Pinned Sticky Cards Deck (3D tilt-off)

Goal

Build a scroll-driven section that holds a deck of four stacked cards pinned in the middle of the viewport. As the user scrolls through the (long) pinned section, the front card flies straight up while tilting back in 3D perspective (rotationX 0→35deg), and the cards stacked behind it slide forward and scale up to take the front position — one card handed off per scroll segment, in a continuous scrubbed loop. Smooth scroll via Lenis. There is an intro panel above and an outro panel below the pinned deck.

Tech

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

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

Register once: gsap.registerPlugin(ScrollTrigger);. Wrap all setup in a DOMContentLoaded listener.

Layout / HTML

Three full-viewport <section>s in order — an intro, the pinned card deck, an outro:

<section class="intro"><h1>Enter the Frame</h1></section>

<section class="sticky-cards">
  <div class="card" id="card-1">
    <div class="col"><p>Quiet Control</p><h1>Signal Drift</h1></div>
    <div class="col"><img src="…card-img-1" alt="" /></div>
  </div>
  <div class="card" id="card-2"> … id="card-2" … </div>
  <div class="card" id="card-3"> … </div>
  <div class="card" id="card-4"> … </div>
</section>

<section class="outro"><h1>Loop Complete</h1></section>
  • Four cards, ids card-1card-4. Each card has exactly two .col children: column 1 holds a small <p> eyebrow at the top and an <h1> title (the two are pushed apart top/bottom); column 2 holds a single <img>.
  • Neutral fictional copy. Suggested per card — eyebrow / title:
  • "Quiet Control" / "Signal Drift"
  • "Fluid Structures" / "Skyline Drift"
  • "Wired Thought" / "Neural Assembly"
  • "Silent Repetition" / "Learning Loop"
  • Intro <h1> "Enter the Frame", outro <h1> "Loop Complete".

Styling

Import fonts:

@import url("https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,100..900;1,100..900&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap");

Palette — four card background colors as CSS variables (all card text is #fff):

  • --base-1: #9d2fa9 (magenta/purple) → #card-1
  • --base-2: #daff22 (acid lime) → #card-2
  • --base-3: #ffdd33 (warm yellow) → #card-3
  • --base-4: #6b7847 (muted olive) → #card-4
  • Deck section background: #e3e3db (warm off-white/greige). Intro & outro sections have no special background (default/transparent).

Typography:

  • h1: text-transform: uppercase; font-family: "Barlow Condensed", sans-serif; font-size: 3rem; font-weight: 800; line-height: 1;
  • p: text-transform: uppercase; font-family: "DM Mono", monospace; font-size: 0.9rem;

Global / reset:

  • * { margin:0; padding:0; box-sizing:border-box; }
  • img { width:100%; height:100%; object-fit:cover; }

Sections & cards (this is the structure the effect hangs on):

  • section { position:relative; width:100%; height:100svh; overflow:hidden; }
  • .intro, .outro { display:flex; justify-content:center; align-items:center; }
  • .sticky-cards { background-color:#e3e3db; perspective:1000px; }the perspective:1000px on the deck container is essential; it is what makes the per-card rotationX read as a real 3D tilt-back rather than a flat squash.
  • .sticky-cards .card { position:absolute; top:50%; left:50%; width:65%; height:60%; display:flex; justify-content:center; align-items:center; gap:1rem; padding:2.5rem; border-radius:1rem; color:#fff; transform-origin:center bottom; will-change:transform; } — all four cards are absolutely stacked at the same center point; transform-origin:center bottom matters so the tilt-back pivots from the card's bottom edge (it hinges away like a page).
  • .card .col { flex:1; height:100%; }
  • .card .col:nth-child(1) { display:flex; flex-direction:column; justify-content:space-between; padding:0.5rem; } (eyebrow top, title bottom)
  • .card .col:nth-child(2) { border-radius:0.75rem; overflow:hidden; } (rounded image frame)
  • Per-card background + stacking order (front-most card first):
  • #card-1 { background-color:var(--base-1); z-index:5; }
  • #card-2 { background-color:var(--base-2); z-index:4; }
  • #card-3 { background-color:var(--base-3); z-index:3; }
  • #card-4 { background-color:var(--base-4); z-index:2; }

Note the cards are centered via CSS top/left:50%, but the actual −50%/−50% centering offsets are applied by GSAP (xPercent/yPercent, below), not CSS transforms.

GSAP effect (the important part — 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);

Lenis is driven by GSAP's ticker (default Lenis options otherwise), Lenis scroll events call ScrollTrigger.update, and lag smoothing is disabled so the scrub stays glued to scroll position.

Constants & card count
const cards = document.querySelectorAll(".sticky-cards .card"); // 4
const totalCards = cards.length;          // 4
const segmentSize = 1 / totalCards;       // 0.25  → one card handed off per 25% of scroll progress
const cardYOffset = 5;                    // yPercent step between stacked cards
const cardScaleStep = 0.075;              // scale step between stacked cards
Initial stack pose (gsap.set per card, before the ScrollTrigger)
cards.forEach((card, i) => {
  gsap.set(card, {
    xPercent: -50,
    yPercent: -50 + i * cardYOffset, // -50, -45, -40, -35
    scale:    1 - i * cardScaleStep, // 1, 0.925, 0.85, 0.775
  });
});

So at rest: the front card (i=0) is perfectly centered at full scale; each card further back is nudged down by 5 yPercent and shrunk by 0.075, peeking out below the one in front — a fanned deck seen from slightly above. (Because transform-origin is center-bottom and cards shrink, smaller cards sit lower and reveal a stepped stack.)

The ScrollTrigger (single pinned, scrubbed trigger — no timeline, all gsap.set inside onUpdate)
ScrollTrigger.create({
  trigger: ".sticky-cards",
  start: "top top",
  end: `+=${window.innerHeight * 8}px`, // 8 viewport heights of scroll distance (computed once at load)
  pin: true,
  pinSpacing: true,
  scrub: 1,                              // 1s smoothing between scroll and animation
  onUpdate: (self) => { /* see below */ },
});
  • The deck section pins at top top and stays pinned for 8× the viewport height of scroll. pinSpacing:true inserts a real spacer so the outro section appears only after the whole deck sequence finishes.
  • scrub: 1 (not true) adds ~1s of easing catch-up on top of Lenis, giving the hand-off a soft, weighted feel.
  • No tweens/timeline — the entire animation is recomputed each frame with gsap.set calls inside onUpdate, driven purely by self.progress.
Per-frame logic inside onUpdate(self)
const progress = self.progress; // 0 → 1 over the whole pin

// which card is the one currently flying out
const activeIndex = Math.min(Math.floor(progress / segmentSize), totalCards - 1); // 0..3
// 0→1 progress *within* the active card's segment
const segProgress = (progress - activeIndex * segmentSize) / segmentSize;

cards.forEach((card, i) => {
  if (i < activeIndex) {
    // already exited: parked off the top, tilted back
    gsap.set(card, { yPercent: -250, rotationX: 35 });
  } else if (i === activeIndex) {
    // the current front card flying out
    gsap.set(card, {
      yPercent:  gsap.utils.interpolate(-50, -200, segProgress), // rises up and off
      rotationX: gsap.utils.interpolate(0, 35, segProgress),     // hinges back in 3D
      scale: 1,                                                  // locked at full size while exiting
    });
  } else {
    // still in the stack behind: advance forward as segProgress grows
    const behindIndex   = i - activeIndex;                        // 1, 2, 3…
    const currentYOffset = (behindIndex - segProgress) * cardYOffset;
    const currentScale   = 1 - (behindIndex - segProgress) * cardScaleStep;
    gsap.set(card, {
      yPercent: -50 + currentYOffset, // slides up toward center as segProgress → 1
      rotationX: 0,
      scale: currentScale,            // scales up toward 1 as it reaches the front
    });
  }
});

Exact behavior to reproduce:

  • Segments: progress 0–0.25 flies out card 0, 0.25–0.5 card 1, 0.5–0.75 card 2, 0.75–1.0 card 3. segProgress is the normalized 0→1 position inside whichever segment is active.
  • Exiting (active) card: yPercent −50 → −200 (translates straight up by ~1.5 card-heights) while rotationX 0 → 35deg (tilts its top edge away from the viewer, hinging on its bottom because of transform-origin:center bottom). Its scale is pinned to 1 throughout the exit — it does not keep any per-index shrink, because by the time a card becomes active the previous segment has already promoted it to full scale.
  • Parked cards (index < activeIndex, i.e. already gone): held at yPercent −250, rotationX 35 — pushed a bit further up than the −200 exit end so they fully clear the frame.
  • Behind cards (index > activeIndex): each advances one "slot" per segment. As segProgress goes 0→1, (behindIndex − segProgress) shrinks by 1, so the nearest behind card (behindIndex 1) moves from yPercent −45 → −50 and scale 0.925 → 1.0 — landing exactly on the front pose right as the active card finishes exiting. The card two-back moves 0.925→0.85 equivalents forward one step, etc. rotationX stays 0 for all behind cards.
  • All interpolation is linear (gsap.utils.interpolate = plain lerp); the only easing comes from scrub: 1 + Lenis. No ease, duration, delay, stagger, SplitText, or CustomEase anywhere.

Net read: a continuous conveyor where the top card ramps up and hinges back into the distance while the deck below marches forward and grows to fill the vacancy, one card per quarter of the scroll.

Assets / images

Four card images, one per card, each rendered inside the card's second column at width:100%; height:100%; object-fit:cover; inside a rounded, overflow-hidden frame. The source files are square (1:1) but are object-fit:cover cropped to the column, so they read as a portrait-to-squarish slice (each column is about half the card's 65vw width and its full 60vh height).

They are a cohesive set of warm, sun-faded vintage-film retro-futurist photographs — 1950s/60s Kodachrome look, grainy, soft, dominated by cream/ivory whites, orange, amber and gold with muted accents. Subjects, in card order:

  1. card-img-1 — two figures in orange jumpsuits with white over-ear headphones, seen from behind at a retro control-panel console, gazing through a window at a starfield with a spiral galaxy and ringed planet (Saturn). Cream cabin, orange suits, deep-blue space.
  2. card-img-2 — a retro-futurist spaceport/building with tapered spires and domes in orange and cream, a vintage two-tone car parked out front, framed by autumn foliage under a hazy warm sky. Orange, cream, amber.
  3. card-img-3 — a person in a cream armchair reading an orange book, their head a transparent glass dome packed with colorful clockwork gears and machinery; sunlit room with bookshelf and lamp. Cream, orange, warm gold.
  4. card-img-4 — a young child writing at a wooden school desk in a sunlit vintage classroom, with a large chrome-and-glass space helmet resting on the desk and a globe nearby. Amber, honey-cream, muted teal chalkboard.

Any cohesive warm-toned retro-futurist photo set works; the shared cream/orange palette reads well against the vivid card backgrounds. Provide 4 files in card order; if fewer are available, repeat in order.

Behavior notes

  • Desktop-first. At max-width:1000px the cards restack vertically: .sticky-cards .card { width:calc(100% - 4rem); height:75%; flex-direction:column; } and .card .col { width:100%; } (image on top of text). The scroll/pin/tilt effect still runs unchanged on all sizes.
  • Section heights use svh so mobile browser chrome doesn't clip the full-viewport panels; overflow:hidden on sections keeps the off-screen exiting cards from creating scrollbars.
  • The end distance is captured once from window.innerHeight at load, so it doesn't reflow on resize (matches the original).
  • No reduced-motion handling in the original — the animation is entirely scroll-scrubbed, so nothing autoplays; it only moves as the user scrolls.