# 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):
```js
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
```js
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
```js
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)
```js
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:

```js
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.**
```js
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).**
```js
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).**
```js
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 leaves** — `index < 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.**
```js
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.
