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
.cardelements inside.sticky-cards. - Card labels (the
.card-content ptext), 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. Keepwill-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.progresspassed(index+1)/6). - The last card (index 5) never leaves —
index < totalCards - 1excludes 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 end1) we currently are,0 → 1.distanceMultiplier = 1 - index*0.15→ card01.0, card10.85, card20.70, card30.55, card40.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
xandygo negative and scale together, so each departing card slides up-and-left. Max reach for card0 isx ≈ -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-cardssection locks in place while the whole deck deals through, then releases into the outro.pinSpacing: truereserves that scroll distance in the document flow. window.innerHeight/window.innerWidthare 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: transformon.card— six simultaneously transformed cards are the heaviest part.