# Sage East 3D Scroll

## Goal
Build a fixed, full-viewport **3D perspective slider** on a black stage. Ten editorial fashion cards sit at staggered `translateZ` depths inside a CSS `perspective` container and **fly toward the camera as you scroll a very tall (2000vh) page** — like flipping through a deck that keeps rushing out of deep space, alternating left and right. A **per-slide `ScrollTrigger` (scrub)** maps scroll progress to a shared Z increment, recomputes each card's `opacity` with a `mapRange` fade, and **cross-fades a blurred, full-screen background image** (`gsap.to`, `power3.out`) as each card reaches the front so the whole screen glows with the ambient color of the frontmost photo. There is no ScrollTrigger tween and no pinning — the scroll span itself is the timeline, and every transform is written by hand inside `onUpdate`.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use **`gsap` (npm)** plus the GSAP plugin **`ScrollTrigger`** — nothing else (no Lenis, no SplitText, no CustomEase, no Three.js). Import as:
```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
```
All setup runs inside a single `window.addEventListener("load", …)` so `getComputedStyle` can read each slide's initial `matrix3d`.

## Layout / HTML
Fixed nav and footer overlay a scroll-driven 3D stage. The `.container` is the tall scroll driver; everything visible is `position: fixed` inside it.

```html
<nav>
  <div class="links-1"><a href="#">Works</a><a href="#">Archive</a></div>
  <div class="logo"><a href="#">Modavate</a></div>
  <div class="links-2"><a href="#">Info</a><a href="#">Contact</a></div>
</nav>

<footer>
  <p>Watch Showreel</p>
  <p>Launching 2024</p>
</footer>

<div class="container">
  <!-- blurred ambient background: the SAME 10 images, stacked & full-screen -->
  <div class="active-slide">
    <img src="/img/1.jpg" alt="" />
    … <img> ×10 in order …
  </div>

  <!-- the 3D deck: 10 flying cards -->
  <div class="slider">
    <div class="slide" id="slide-1">
      <div class="slide-copy">
        <p>Neo Elegance°</p>
        <p id="index">( ES 2023 0935 )</p>
      </div>
      <div class="slide-img"><img src="/img/1.jpg" alt="" /></div>
    </div>
    … slide-2 … slide-10 (each with its own copy + image) …
  </div>
</div>
<script type="module" src="./script.js"></script>
```
Ten slides, ids `slide-1` … `slide-10`. Each `.slide` holds a `.slide-copy` (a title `<p>` plus a `<p id="index">` catalog code) and a `.slide-img > img`. Sample titles (fictional): Neo Elegance°, Future Luxe, Cyber Glam, Visionary Threads, Galactic Chic, Tech Sophistication, Avant Edge, Moda Futura, Eco Futurist, Sleek Tomorrow. Index codes read like `( ES 2023 0935 )`, `0936`, … `0944`. Every card also has a **twin image** in `.active-slide`, in the same order, which becomes its blurred background.

## Styling
- **Palette:** `--color-accent: rgb(230, 170, 40)` (warm amber/gold) for all text; page `background: #000`.
- Global reset `* { margin:0; padding:0; box-sizing:border-box }`; `img { width:100%; height:100%; object-fit:cover }`.
- **Type:** all `a`/`p` are `text-transform:uppercase; font-size:12px; color:var(--color-accent)`, font family a neutral grotesque (original uses "Basis Grotesque Pro" — substitute any clean sans, e.g. system-ui/Helvetica). The `.logo a` and slide titles use an **extended display font** (original "PP Monument Extended" — substitute any wide/extended weight, or a bold condensed-inverse feel): logo `16px`, `font-weight:bolder`, `letter-spacing:-0.02em`; slide title `13px`, `bolder`, `line-height:150%`, centered. `p#index` uses the grotesque at `11px`, `font-weight:400`, `margin-bottom:0.75em`.
- **nav:** `position:fixed; top:0; width:100%; padding:1.5em 2em; display:flex; align-items:center`. Its three `> div` children each `flex:1`; `.links-1`/`.links-2` are `display:flex; gap:2em` (links-2 justified flex-end); `.logo` centers its link.
- **footer:** `position:fixed; bottom:0; width:100%; padding:1.5em 2em; display:flex; justify-content:space-between; align-items:center`.
- **.container:** `width:100%; height:2000vh` — the scroll length that drives the whole effect.
- **.active-slide** (ambient backdrop): `position:fixed; inset:0; width:100%; height:100%; overflow:hidden; background:#000; opacity:0.35; z-index:-1`. Its `img`s are `position:absolute; filter:blur(50px); transform:scale(1.125)` — stacked full-screen; the frontmost non-faded one shows through as a soft, out-of-focus color wash.
- **.slider** (the 3D camera): `position:fixed; top:0; width:100vw; height:100vh; overflow:hidden; transform-style:preserve-3d; perspective:750px`. **This `perspective:750px` is the lens** — it makes the deep negative-Z slides read as tiny far-away specks that balloon to full size as they approach `z:0`.
- **.slide:** `position:absolute; width:400px; height:500px; overflow:hidden` (a 4:5 card).

### Initial slide placement (critical — the JS reads these back)
Each `#slide-N` is absolutely positioned at `top:50%`, with `transform:translateX(-50%) translateY(-50%) translateZ(<Zn>px)`. Odd slides sit at `left:70%`, even slides at `left:30%` — so as they rush forward they **alternate right / left** of center. The Z depths step by **+2500px**, deepest first:

| slide | left | translateZ | opacity |
|------|------|-----------|---------|
| 1 | 70% | −22500px | 0 |
| 2 | 30% | −20000px | 0 |
| 3 | 70% | −17500px | 0 |
| 4 | 30% | −15000px | 0 |
| 5 | 70% | −12500px | 0 |
| 6 | 30% | −10000px | 0 |
| 7 | 70% | −7500px | 0 |
| 8 | 30% | −5000px | 0 |
| 9 | 70% | −2500px | 0.5 |
| 10 | 30% | 0px | 1 |

Only slides 9 (`opacity:0.5`) and 10 (`opacity:1`) are visible at rest; everything else is fully transparent far away. Slide 10 is the hero at the camera plane; slide 9 is the half-faded one just behind it.

## GSAP effect (the important part — be exhaustive)

### Two helpers
```js
// read the CURRENT translateZ out of the computed matrix3d (m43 = 15th value, index 14)
function getInitialTranslateZ(slide) {
  const style = window.getComputedStyle(slide);
  const matrix = style.transform.match(/matrix3d\((.+)\)/);
  if (matrix) {
    const values = matrix[1].split(", ");
    return parseFloat(values[14]) || 0;   // the z translation, e.g. -22500 … 0
  }
  return 0;
}

// plain linear remap
function mapRange(value, inMin, inMax, outMin, outMax) {
  return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
}
```

### One ScrollTrigger per slide
```js
const slides = gsap.utils.toArray(".slide");
const activeSlideImages = gsap.utils.toArray(".active-slide img");

slides.forEach((slide, index) => {
  const initialZ = getInitialTranslateZ(slide);   // -22500 for slide1 … 0 for slide10

  ScrollTrigger.create({
    trigger: ".container",
    start: "top top",
    end: "bottom bottom",
    scrub: true,
    onUpdate: (self) => {
      const progress = self.progress;             // 0 → 1 across the full 2000vh
      const zIncrement = progress * 22500;         // every slide travels +22500px in Z
      const currentZ = initialZ + zIncrement;

      // opacity from depth, in two linear bands
      let opacity;
      if (currentZ >= -2500) {
        opacity = mapRange(currentZ, -2500, 0, 0.5, 1);   // near front: 0.5 → 1
      } else {
        opacity = mapRange(currentZ, -5000, -2500, 0, 0.5); // approaching: 0 → 0.5
      }
      slide.style.opacity = opacity;

      // write the live depth
      slide.style.transform =
        `translateX(-50%) translateY(-50%) translateZ(${currentZ}px)`;

      // cross-fade THIS slide's blurred background twin
      if (currentZ < 100) {
        gsap.to(activeSlideImages[index], 1.5, { opacity: 1, ease: "power3.out" });
      } else {
        gsap.to(activeSlideImages[index], 1.5, { opacity: 0, ease: "power3.out" });
      }
    },
  });
});
```

### Exactly how it reads on screen
- **Trigger / span:** `trigger:".container"`, `start:"top top"`, `end:"bottom bottom"`, `scrub:true`. There is **no tween attached** and **no pin** — `onUpdate` is the whole animation. `self.progress` runs 0→1 over the 1900vh of real scroll, and `scrub:true` keeps `progress` glued to the scrollbar so the deck tracks the wheel directly (no lag, no autoplay).
- **Shared Z increment:** every slide adds the *same* `progress * 22500` to its own `initialZ`. Because the ten `initialZ` values are spaced 2500px apart, the cards **arrive at the camera one after another**, evenly staggered — a rolling procession, not a single move. At `progress:1` slide-1 lands exactly at `z:0` (its start −22500 + 22500) while slide-10 has flown to `z:22500` (blown past the lens and clipped away).
- **Opacity bands (`mapRange`):** a slide is invisible until it's within 5000px of the front. From `z:−5000 → −2500` opacity ramps `0 → 0.5`; from `z:−2500 → 0` it ramps `0.5 → 1`; at/after `z:0` `mapRange(currentZ,-2500,0,0.5,1)` keeps returning ≥1 so it stays full-opacity as it rushes through and past the viewer. This is why slide-9 starts at 0.5 and slide-10 at 1 — the formula evaluated at their resting Z.
- **Ambient background cross-fade:** each slide owns the same-index image inside `.active-slide`. While a slide is at or in front of the lens (`currentZ < 100`) its blurred twin fades **in** to `opacity:1`; once it passes (`currentZ ≥ 100`) the twin fades **out** to `0` — both over **1.5s with `power3.out`**. The `.active-slide` images are stacked full-screen, `blur(50px) scale(1.125)`, inside a container at `opacity:0.35`, so what you actually see is a soft, defocused wash of the frontmost card's colors bleeding across the whole black screen, hand-off blending from one photo to the next as the deck advances.
- **Transform authored by hand:** `slide.style.transform` and `slide.style.opacity` are set imperatively every `onUpdate`; GSAP is only used for the 1.5s background tweens. No timeline, no labels, no stagger config — the *stagger is baked into the initial Z offsets*, and the *easing of the flight is linear* (direct scrub), with the only eased tween being the `power3.out` background fade.

## Assets / images
**10 editorial fashion photographs**, portrait orientation ~**2:3** (each shown twice: once as a crisp `400×500` 4:5 card via `object-fit:cover`, once as a full-screen `blur(50px)` background twin). Keep them a cohesive but varied high-fashion set with a warm, saturated, editorial mood, e.g.: tight **profile-portrait headshots on flat saturated backdrops** (violet, cobalt blue); **full-length couture looks on a solid color seamless** (a deep-red gown/coat against electric blue); **motion / twirl garment shots** in a neutral studio (a beige wool coat caught mid-spin); **backlit, soft-focus portraits among blossoms** (hazy pastel bloom); and **flowing silk-fabric abstractions on pastel grounds** (amber/orange satin ribboning across teal). Provide 10 files named `1.jpg … 10.jpg`; if fewer are available, repeat in order. No real brand names — use the fictional "Modavate" wordmark and the invented collection titles above.

## Behavior notes
- **Scroll-only, wheel/trackpad/scrollbar driven** — no click, hover, or keyboard; nothing autoplays. At scroll top you see slide-10 sharp and slide-9 half-faded, deep-space behind.
- **Desktop-tuned:** card size and Z depths are fixed px (400×500, up to −22500), and `perspective:750px` is calibrated for a large viewport; there are no responsive breakpoints in the original.
- The **2000vh** page height gives a long, slow reveal — roughly one slide surfacing per ~200vh of scroll.
- No reduced-motion branch in the original; motion is entirely user-driven, but the background fades keep their 1.5s `power3.out` glide after the scroll input.
