# Sticky Cards — Scroll-Pinned 3D Flip & One-by-One Dismiss

## Goal
Build a full-screen, scroll-pinned hero where a stack of cards rises into view while the headline slides out the top; past the halfway point a single "front" card performs a springy 3D flip to reveal four tilted, colored cards fanned on top of each other, which are then dismissed upward one at a time as you keep scrolling. The star effect is the **elastic 3D card flip driven by a long scrubbed, pinned ScrollTrigger**, combined with a **staggered per-card dismiss** keyed to precise scroll-progress windows.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Build under Vite (npm). Use:
- `gsap` (npm) with the plugin **`ScrollTrigger`** (`gsap/ScrollTrigger`), registered via `gsap.registerPlugin(ScrollTrigger)`.
- `lenis` (npm) for smooth scroll, wired into GSAP's ticker.
- Icon glyphs via the **ionicons** web component (`ion-icon`), loaded from its ESM CDN build (`ionicons@7.x`, `ionicons.esm.js` as a module, plus the `nomodule` fallback). No raster images are used.

Wire Lenis to GSAP exactly like this:
```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 regions. Every `section` is `position: relative; width: 100%; height: 100svh; overflow: hidden;`.

1. `section.hero` — contains two absolutely-positioned overlays:
   - `div.hero-content` → `h1` headline. Neutral copy, e.g. **"Scroll down and watch everything fall into place"**.
   - `div.sticky-cards` → the card stack. It holds, in this DOM order:
     - **1** `div.card.card-front` with `h3` ("First Frame"), a `span` badge ("Start here"), a `p`, and a `div.icon` (an `ion-icon` such as `caret-down`).
     - **4** `div.card.card-back`, each with `id="card-1"` … `id="card-4"`, each containing an `h3` title, a `div.icon` with an `ion-icon`, and a `p`. Suggested titles/icons: card-1 "Final Hold" (`lock-open`), card-2 "Layered Time" (`layers`), card-3 "Weight & Flow" (`prism`), card-4 "Soft Motion" (`infinite`).
2. `section.about` — a centered `h3` with a short reflective sentence (e.g. "A quiet progression of motion and stillness, where each layer reveals itself with intention and nothing feels out of place."), width 60% (85% under 1000px), text-align center.

## Styling
Fonts (Google Fonts): **Barlow Condensed** for `h1`/`h3` (weight 900, `text-transform: uppercase`, `line-height: 0.85`), **DM Sans** for body. Sizes: `h1` `clamp(3rem, 5vw, 7rem)`; `h3` `clamp(2rem, 3vw, 5rem)`; `p` `1.125rem`/weight 450/line-height 1.1; `span` badge uppercase 0.9rem, padding 0.5rem, radius 0.25rem, white bg, dark text.

Color tokens (CSS variables):
- `--base-100: #fff`
- `--base-200: #fbfff2` (hero background)
- `--base-300: #dfebe0` (about background)
- `--base-400: #f0fd00` (acid yellow — front card + card-1)
- `--base-500: #dfebe0` (card-2)
- `--base-600: #8c26fd` (violet — card-3)
- `--base-700: #9bfd40` (lime — card-4)
- `--base-800: #0f0f0f` (near-black text)

`.hero` bg `--base-200`, text `--base-800`. `.hero-content` is `position: absolute; width:100%; height:100svh;` flex-centered, `will-change: transform`; its `h1` is width 60%, centered.

**Card stack container** `.sticky-cards`: `position: absolute; width:100%; height:100svh; overflow:hidden;` and critically **`transform-style: preserve-3d; perspective: 1000px;`** — this perspective is what makes the flip read as 3D.

**Every `.card`**: `position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:25%; min-width:300px; aspect-ratio:4/5; padding:4rem 2rem; border-radius:1rem;` flex column, `justify-content:space-between; align-items:center; text-align:center; will-change:transform;` default bg `--base-400`, color `--base-100`. All five cards therefore occupy the **exact same centered spot**, stacked.

- `.card-front` overrides transform to `translate(-50%, 50%) rotateY(0deg)` and sets `backface-visibility: hidden`. Its `.icon` is a 4rem circle with a `0.125rem` white border.
- `.card-back` overrides transform to `translate(-50%, 50%) rotateY(180deg)` and sets `backface-visibility: hidden`. Its `.icon` is a 5rem filled white circle, font-size 1.5rem, colored `--base-400`.
- Per-card back colors: `#card-1` bg `--base-400`/text `--base-100`; `#card-2` bg `--base-500`/text `--base-800`; `#card-3` bg `--base-600`/text `--base-100`; `#card-4` bg `--base-700`/text `--base-800`.

Because both faces use `backface-visibility: hidden`: at rest only the single front card is visible; the four back cards are rotated so their reverse faces the viewer and they are invisible until the flip.

`.about`: flex-centered, bg `--base-300`, text `--base-800`, `h3` width 60% (85% under 1000px).

## GSAP effect (be exhaustive)

### Selectors & constants
```
stickyCards      = all .card            (5 elements: 1 front + 4 back)
frontStickyCard  = .card-front          (1)
backStickyCards  = all .card-back        (4, in DOM order card-1..card-4)
heroHeadline     = .hero-content
stickyCardCount  = 4
```
Scroll-length budget expressed in svh units:
```
CARDS_ENTER_END        = 100
CARD_FLIP_TRIGGER      = 200
CARD_DISMISS_START     = 300
CARD_DISMISS_DURATION  = 100
TOTAL_SCROLL_SVH       = CARD_DISMISS_START + stickyCardCount * CARD_DISMISS_DURATION  = 700
svhToProgress(svh)     = svh / TOTAL_SCROLL_SVH          // maps an svh milestone to 0..1 progress
totalScroll(px)        = window.innerHeight * (TOTAL_SCROLL_SVH / 100)  = 7 * innerHeight
```
Tilt tables (index-aligned to the four back cards in DOM order):
```
cardFlipTiltAngles    = [-10, -20, -5, 10]    // rotationZ each card lands on when revealed
cardDismissTiltAngles = [-50, -60, -45, 50]   // rotationZ each card ramps to while dismissed
```

### Per-card dismiss windows (reverse order — this matters)
For each back card `i` (0..3), compute `dismissOrder = stickyCardCount - 1 - i`, then its progress window is
`[svhToProgress(CARD_DISMISS_START + dismissOrder*100), svhToProgress(CARD_DISMISS_START + (dismissOrder+1)*100)]`.
This makes the **last card in the DOM dismiss first**. Resulting windows (progress 0..1):
- card-4 (i=3) → `[0.4286, 0.5714]` (dismisses **first**)
- card-3 (i=2) → `[0.5714, 0.7143]`
- card-2 (i=1) → `[0.7143, 0.8571]`
- card-1 (i=0) → `[0.8571, 1.0]` (dismisses **last**)

Back-to-back, non-overlapping, each spanning 100/700 of progress.

### Initial state
```
gsap.set(frontStickyCard,  { rotationY: 0 });
gsap.set(backStickyCards,  { rotationY: -180 });
let isFlipped = false;
```

### The flip — imperative one-shot elastic tweens (NOT scrubbed)
Two helper functions fire a single spring animation when the flip threshold is crossed. All tweens: `duration: 1, ease: "elastic.out(1, 0.5)"`.

`revealBackCards()`:
- `frontStickyCard` → `rotationY: 180`
- each back card `i` → `rotationY: 0`, `rotationZ: cardFlipTiltAngles[i]`

`concealBackCards()` (reverse):
- `frontStickyCard` → `rotationY: 0`
- each back card → `rotationY: -180`, `rotationZ: 0`

### The scrubbed, pinned ScrollTrigger (the driver)
```
ScrollTrigger.create({
  trigger: ".hero",
  start: "top top",
  end: `+=${totalScroll}px`,   // 7 * innerHeight
  pin: true,
  pinSpacing: true,
  scrub: true,
  onUpdate: ({ progress }) => { ... }
});
```
Inside `onUpdate`, every frame, using `gsap.utils.mapRange` and `gsap.utils.clamp`:

1. **Enter phase** — `enterProgress = clamp(0, 1, mapRange(0, svhToProgress(100), 0, 1, progress))` (i.e. progress 0 → 0.1428 maps to 0 → 1, clamped after).
   - `gsap.set(stickyCards, { y: mapRange(0, 1, 50, -50, enterProgress) + "%" })` — **all five cards** rise from `y: 50%` to `y: -50%`.
   - `gsap.set(heroHeadline, { y: mapRange(0, 1, 0, -100, enterProgress) + "%" })` — headline exits from `y: 0%` to `y: -100%`.

2. **Flip trigger** (one-shot, latched by `isFlipped`):
   - if `progress > svhToProgress(200)` (0.2857) and `!isFlipped` → `revealBackCards(); isFlipped = true;`
   - else if `progress <= 0.2857` and `isFlipped` → `concealBackCards(); isFlipped = false;`

3. **Dismiss phase** — for each back card `i`, read its `[dismissStart, dismissEnd]` window, compute `dismissProgress = clamp(0, 1, mapRange(dismissStart, dismissEnd, 0, 1, progress))`, then:
   - `gsap.set(card, { y: mapRange(0, 1, -50, -250, dismissProgress) + "%", rotation: mapRange(0, 1, cardFlipTiltAngles[i], cardDismissTiltAngles[i], dismissProgress) })`.
   - So each back card slides from `y: -50%` up to `y: -250%` while its z-rotation ramps from its flip tilt to its dismiss tilt, only within its own scroll window. Note `rotation` here is the 2D z-rotation (alias of `rotationZ`), so it continues from where the flip left it.

Notes on interplay: the enter `gsap.set(stickyCards, …)` writes `y` on the back cards too, but since each back card's `dismissProgress` clamps to 0 before its window opens, its `y` sits at `-50%` — continuous with the group. The front card only ever receives the group `y`, so after the enter phase it rests at `y: -50%` and then flips away, staying put.

## Behavior notes
- Desktop-oriented, scroll-driven; the whole hero is pinned for `7 × viewport height` of scroll, so the page is tall. `pinSpacing: true` reserves that scroll length.
- The flip is a discrete spring (elastic) that plays once each direction as you cross the midpoint; the enter and dismiss motions are continuously scrubbed. Scrolling back up conceals the cards and reverses everything.
- Under `max-width: 1000px`, widen `.hero-content h1` and `.about h3` to 85%. Cards keep `min-width: 300px`.
- Keep `perspective`, `transform-style: preserve-3d`, and `backface-visibility: hidden` — without them the 3D flip and the hide-the-reverse-face behavior break.
