# Pinned Horizontal-Scroll 404 — Giant Serif Headline Pans Sideways While Image Cards Parallax and Rotate

## Goal
Build a **full-screen black 404 page** whose star effect is a **pinned, scroll-driven horizontal pan**. A colossal white serif headline ("Page Not Found", set at `48vw`) lives on a `400vw`-wide stage; as the visitor scrolls **down**, the whole stage is pinned in place and slides **left** (its `x` goes `0 → −350vw`), so the giant letters sweep across the viewport from right to left. Scattered over the headline are **four small rounded image cards** that each **drift sideways at their own rate and rotate** as you scroll — a layered parallax. When the pan completes, a centered closing message ("This page doesn't exist anymore… but that's okay. We'll get you right back on the track!") is revealed on the now-empty black stage. Everything is driven by GSAP **ScrollTrigger** (`scrub` + `pin`) with a distinctive double-smoothed, trailing feel.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the single GSAP plugin **`ScrollTrigger`**. No smooth-scroll library, no canvas/WebGL, no SplitText. Import as:
```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
```
All animation code runs inside a `DOMContentLoaded` listener.

## Layout / HTML
```
.container                                   (the tall scroll driver — height: 1200vh)
  nav > a  "Motionprompts"                    (fixed strip, top-center)

  section.wrapper-404                         (the 400vw-wide pinned stage)
    h1  "Page Not Found"                      (one line, 48vw serif, spans the whole stage)
    .card#card-1 > img                        (4 absolutely-positioned square image cards)
    .card#card-2 > img
    .card#card-3 > img
    .card#card-4 > img

  section.outro                               (the closing message, lower in the container)
    h1  "This page doesn't exist anymore... but that's okay. <br> We'll get you right back on the track!"

script type="module" src="./script.js"
```
Key classes/ids the JS depends on: `.wrapper-404`, `#card-1`…`#card-4`. Nav brand text is the neutral demo name **"Motionprompts"** — no real brand marks.

## Styling
Global reset: `* { margin:0; padding:0; box-sizing:border-box; }`. `html, body { width:100%; height:100%; background:#000; overflow-x:hidden; }`. `img { width:100%; height:100%; object-fit:cover; }`.

**Palette:** pure black `#000` background, white `#fff` text. The image cards supply all the color.

**Font:** `html, body { font-family: "Roslindale Display Condensed"; }` — a proprietary **high-contrast condensed serif display** face. **No `@font-face`/web-font is loaded**, so it simply falls back to the platform default serif (Times-like); substitute any elegant high-contrast serif (e.g. a Didone/Times-style display serif). The look is defined by the sizes below, not the family name.

**Load-bearing structural CSS** (the pin math and transforms depend on this geometry):
- `.container { width:100%; height:1200vh; }` — the tall element that creates the page's scroll length.
- `nav { position:fixed; top:0; width:100%; padding:1em; display:flex; justify-content:center; }`; `a { text-decoration:none; color:#fff; font-size:20px; }`.
- `h1 { width:100%; color:#fff; font-size:48vw; font-weight:400; text-align:center; margin:0; }` — the giant headline; because it's `width:100%` inside the `400vw` stage, it stretches across the whole stage as one enormous line.
- `.wrapper-404 { position:absolute; top:0; width:400vw; height:100vh; will-change:transform; }` — **the stage GSAP pins and translates on `x`.** It is 4 viewports wide and exactly 1 viewport tall.
- `.card { position:absolute; width:300px; height:300px; background:gray; border-radius:20px; overflow:hidden; }` — a fixed 300×300 rounded square; the `<img>` inside fills it via `object-fit:cover`.
- Card anchor positions (percentages are relative to the `400vw` stage, so they're spread across its width):
  - `#card-1 { top:50%; left:20%; }`
  - `#card-2 { top:25%; left:40%; }`
  - `#card-3 { top:45%; left:60%; }`
  - `#card-4 { top:15%; left:80%; }`
- `.outro { position:absolute; top:150vh; width:100%; height:100vh; }`, and `.outro h1 { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:max-content; font-size:40px; font-weight:lighter; text-align:center; }` — a small centered two-line message (NOT the giant 48vw size; this h1 is overridden to 40px).
- Mobile tweak: `@media (max-width:900px){ .wrapper-404 { padding-top:20em; } }`.

## GSAP effect (exhaustive — this is the whole component)

Two kinds of ScrollTrigger, all created once inside `DOMContentLoaded`. **Note the unusual pattern:** every trigger uses `scrub` **and** re-issues a short `gsap.to()` tween inside its `onUpdate`, so the motion is *double-smoothed* (scrub inertia + a trailing 0.5s `power3.out` on top). This gives the pan and the cards a soft, laggy, momentum-like glide rather than a rigid 1:1 scrub.

### Card data
```js
const cards = [
  { id: "#card-1", endTranslateX: -2000, rotate:  45 },
  { id: "#card-2", endTranslateX: -1000, rotate: -30 },
  { id: "#card-3", endTranslateX: -2000, rotate:  45 },
  { id: "#card-4", endTranslateX: -1500, rotate: -30 },
];
```

### 1. Main ScrollTrigger — pin the stage + horizontal pan
```js
ScrollTrigger.create({
  trigger: ".wrapper-404",
  start: "top top",
  end: "+=900vh",
  scrub: 1,
  pin: true,
  onUpdate: (self) => {
    gsap.to(".wrapper-404", {
      x: `${-350 * self.progress}vw`,
      duration: 0.5,
      ease: "power3.out",
    });
  },
});
```
Precise behavior:
- **`pin: true`** freezes `.wrapper-404` in the viewport for the whole active range while the page scrolls; **`start:"top top"`** (stage top hits viewport top → the very start of the page) and **`end:"+=900vh"`** define the pin length.
- **`scrub: 1`** ties the trigger's own `self.progress` to the scrollbar with ~1s catch-up smoothing.
- **`onUpdate`** re-tweens the pinned stage's `x` **on the same `.wrapper-404` element** every update: target `x = −350·progress` **vw**. So `progress 0 → x:0` (headline starts, first letters on screen), `progress 1 → x:−350vw` (the whole 400vw stage has swept left by 3.5 viewports, headline gone, black stage). Each `onUpdate` overwrites the in-flight 0.5s `power3.out` tween, layering a trailing ease over the scrub.

### 2. Per-card ScrollTriggers — parallax x-drift + rotation
For **each** of the four cards, create an independent ScrollTrigger:
```js
cards.forEach((card) => {
  ScrollTrigger.create({
    start: "top top",
    end: "+=1200vh",
    scrub: 1,
    onUpdate: (self) => {
      gsap.to(card.id, {
        x:      `${card.endTranslateX * self.progress}px`,
        rotate: `${card.rotate * self.progress * 2}`,
        duration: 0.5,
        ease: "power3.out",
      });
    },
  });
});
```
Precise behavior:
- These four triggers are **NOT anchored to the card elements**. In the original they are created with a trigger selector that does not resolve to any real element, so each one effectively measures **overall page-scroll progress from the top of the page** (`start` at the page top, running for `end:"+=1200vh"`). Reproduce that: do **not** pass a card element as `trigger` — let each card trigger track the page's scroll progress. (If you tie them to the card elements instead, the progress mapping changes and the parallax rates come out wrong.)
- Per update, each card is tweened to `x = endTranslateX · progress` **px** and `rotate = (rotate · progress · 2)` **degrees** — again via a re-fired `gsap.to(..., {duration:0.5, ease:"power3.out"})`, so cards glide/lag exactly like the stage.
- **End values at `progress = 1`** (the flung, rotated resting state):
  - `#card-1`: `x −2000px`, rotate **+90°** (`45·2`)
  - `#card-2`: `x −1000px`, rotate **−60°** (`−30·2`)
  - `#card-3`: `x −2000px`, rotate **+90°**
  - `#card-4`: `x −1500px`, rotate **−60°**
- Because the cards' end distance is `+=1200vh` while the stage's pin is `+=900vh`, the cards travel/rotate at a **different (slower) rate than the stage pans** — that rate mismatch is the parallax: cards slide/spin at a `900:1200` (3:4) pace relative to the headline, some clockwise (`+45`) and some counter-clockwise (`−30`), so they scatter, tilt, and fly off-screen left as the pan finishes.

### Net journey (what's on screen)
- **Start (progress 0):** nav "Motionprompts" top-center; the giant serif "P a g…" of "Page Not Found" filling the frame; cards sitting at their anchor spots (e.g. card #1 lower-right).
- **Mid:** the stage slides left ("…Found" letters sweep through), each card drifting left and tilting at its own angle, overlapping the letterforms.
- **End (progress 1):** stage panned fully `−350vw`, headline gone, cards flung far left off-screen — an almost-empty black page with the small centered outro line ("This page doesn't exist anymore… but that's okay. We'll get you right back on the track!") revealed.

## Assets / images
**4 images**, each shown inside a **300×300 rounded (`border-radius:20px`) square card**, hard-cropped with `object-fit:cover` (so source aspect is forgiving — mix of portrait and landscape works). This is an **intentionally eclectic, non-matching set** (they don't form one series). By role/order:
1. **#card-1** — a moody **product still-life**: a sleek matte-aluminium gadget (rounded-rectangle device with a circular directional-pad button and a USB-C-style port) shot close-up on a dark charcoal surface with soft dramatic lighting. (Source portrait ~2:3.)
2. **#card-2** — a bold **pop-art / comic-style illustration**: a woman with stylized **blue** skin wearing **yellow-framed** sunglasses and a blue/yellow/black striped sweater, against a flat **bright-red** background. (Portrait ~2:3.)
3. **#card-3** — another **pop-art comic portrait**: a woman with a dark bob and blunt fringe, **blue** skin tones and blue lips, wearing a navy-and-cream Breton-striped top, against a flat **bright-blue** background. (Portrait ~2:3.)
4. **#card-4** — an **aerial landscape photo**: a **turquoise river** meandering in an S-curve through vast green grassland and rolling hills under a pale sky. (Landscape ~3:2.)

## Behavior notes
- **Scroll-only, no autoplay** — nothing moves until the user scrolls; at rest the stage sits at `x:0`. There is no loop and no reverse-autoplay; scrolling back up simply rewinds the scrubbed timeline.
- **The whole sequence lives inside the pin.** Because the `onUpdate` tweens `x` on the *same* pinned `.wrapper-404`, and the container is `1200vh`, the entire pan + card parallax completes within a compact scroll span and then holds — scrolling to the bottom leaves the end/outro state on screen.
- **Desktop-first** (the giant `48vw` headline and pinned horizontal pan are built for wide viewports); the mobile media query only nudges the stage down with `padding-top`.
- **Reduced motion:** the original has no `prefers-reduced-motion` guard; all motion is user-scroll-driven, so it's calm when the user isn't scrolling.
