# Scroll-Grow Showreel Video Reveal (Scrub + Mouse Parallax)

## Goal
Build a minimal editorial landing page where a **tiny video-preview thumbnail (scaled to 0.25, pulled up above its section) grows into a full-width 16:9 showreel as the intro section scrolls into view**. A GSAP ScrollTrigger scrub timeline drives nothing directly — its `onUpdate` only interpolates values into a shared state object (`translateY`, `scale`, column `gap`, and a **two-phase title font-size**), and a `requestAnimationFrame` loop applies them as a transform string, adding an **eased mouse-follow horizontal parallax** that fades out as the video reaches full scale. Scroll is smoothed with Lenis. Desktop-only effect.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use **`gsap` (npm)** with the **`ScrollTrigger`** plugin, plus **`lenis`**:
```js
import Lenis from "lenis";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
```
Everything runs inside a `DOMContentLoaded` listener, and the **entire script body is wrapped in `if (window.innerWidth >= 900) { ... }`** — below 900px no Lenis, no ScrollTrigger, no rAF loop at all. Wire Lenis the standard way:
```js
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
```
No tweens and no eases anywhere — the ScrollTrigger `onUpdate` computes linear interpolations with `gsap.utils.interpolate`, and a rAF loop writes inline styles.

## Layout / HTML
```html
<body>
  <nav>
    <div class="logo"><a href="#">Motionprompts</a></div>
    <div class="links">
      <a href="#">Home</a><a href="#">About</a><a href="#">Videos</a><a href="#">Contact</a>
    </div>
  </nav>

  <section class="hero">
    <h1>Motionprompts</h1>
    <div class="hero-copy">
      <p>One subscription, endless web design.</p>
      <p>(Scroll)</p>
    </div>
  </section>

  <section class="intro">
    <div class="video-container-desktop">
      <div class="video-preview">
        <div class="video-wrapper">
          <img class="showreel-img" src="(showreel)" alt="PRO Showreel" />
        </div>
      </div>
      <div class="video-title">
        <p>PRO Showreel</p>
        <p>2023 - 2024</p>
      </div>
    </div>

    <div class="video-container-mobile">
      <!-- exact same inner markup: .video-preview > .video-wrapper > img, then .video-title with the two <p> -->
    </div>
  </section>

  <section class="outro">
    <p>Delve into coding without clutter.</p>
  </section>
  <script type="module" src="./script.js"></script>
</body>
```
The JS queries `.intro`, `.video-container-desktop`, and `.video-title p` — keep those class names exact. Note the duplicated video block: `.video-container-desktop` (animated) and `.video-container-mobile` (static fallback, hidden on desktop).

## Styling
Font: **"PP Neue Montreal"** loaded via `@import url("https://fonts.cdnfonts.com/css/pp-neue-montreal");`. Palette: text `#1a1a1a` on warm paper `#e3e3db`; the video placeholder plate is `#b9b9b3`; nav links are `#fff` (inverted by blend mode).

- Global reset `* { margin:0; padding:0; box-sizing:border-box; }`; `body { font-family:"PP Neue Montreal"; color:#1a1a1a; background:#e3e3db; overflow-x:hidden; }`.
- Base type: `h1 { font-size:60px; font-weight:500; }`, `p { font-size:20px; font-weight:500; }`, `a { text-decoration:none; color:#fff; font-size:20px; font-weight:500; }`.
- `nav`: `position:fixed; top:0; left:0; width:100vw; padding:2em 2.5em; display:flex; justify-content:space-between; mix-blend-mode:difference; z-index:2;` — the white links invert against the light background. `.links { display:flex; gap:1em; }`.
- Every `section`: `width:100vw; height:100svh; padding:2.5em;`.
- `.hero`: flex column, `justify-content:space-between`, `padding-top:4em`. Its `h1`: `position:relative; left:-0.05em; text-transform:uppercase; font-weight:500; font-size:20vw; letter-spacing:-0.04em; line-height:1;` — a giant single-line wordmark. `.hero-copy { display:flex; justify-content:space-between; align-items:flex-end; }` (tagline left, "(Scroll)" right).
- `.intro { height:100%; }` (overrides the `100svh`).
- `.video-container-desktop`: `position:relative; display:flex; flex-direction:column; gap:2em; will-change:transform;` with **initial state in CSS: `transform: translateY(-105%) scale(0.25);`** — before JS runs it must already sit pulled up above the intro (overlapping the hero as a small centered thumbnail) at quarter scale. Its `.video-title p { position:relative; font-size:78px; font-weight:500; }` (the JS immediately takes over the font-size).
- `.video-container-mobile`: `display:none; width:100%; max-width:800px; margin:0 auto;`.
- `.video-preview`: `position:relative; width:100%; aspect-ratio:16/9; border-radius:1.5rem; background:#b9b9b3; overflow:hidden;`. `.video-wrapper`: absolutely positioned full-bleed inside it, same `border-radius:1.5rem; overflow:hidden;`. The img: `width:100%; height:100%; object-fit:cover; display:block;`.
- `@media (max-width:900px)`: `nav, section { padding:1.5em; }`; `.hero { justify-content:flex-end; gap:2em; }`; `.hero h1 { font-size:19vw; }`; `.video-container-desktop { display:none; }`; `.video-container-mobile { display:flex; flex-direction:column; gap:1em; }`.

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

### 1. Responsive initial values (breakpoints table)
The starting `translateY` (in **%** of the container's own height) and the mouse-parallax strength depend on viewport width. Check in order, first match wins:
```js
const breakpoints = [
  { maxWidth: 1000, translateY: -135, movMultiplier: 450 },
  { maxWidth: 1100, translateY: -130, movMultiplier: 500 },
  { maxWidth: 1200, translateY: -125, movMultiplier: 550 },
  { maxWidth: 1300, translateY: -120, movMultiplier: 600 },
];
// width > 1300 → { translateY: -105, movementMultiplier: 650 }
```
A `getInitialValues()` helper returns `{ translateY, movementMultiplier }` for the current `window.innerWidth`.

### 2. Shared animation state
All animated values live in one plain object (no GSAP targets):
```js
const animationState = {
  scrollProgress: 0,
  initialTranslateY: initialValues.translateY,
  currentTranslateY: initialValues.translateY,
  movementMultiplier: initialValues.movementMultiplier,
  scale: 0.25,
  fontSize: 80,
  gap: 2,
  targetMouseX: 0,
  currentMouseX: 0,
};
```
On `resize`: recompute `getInitialValues()`, overwrite `initialTranslateY` and `movementMultiplier`, and **only reset `currentTranslateY` if `scrollProgress === 0`** (don't snap mid-scroll).

### 3. The ScrollTrigger (scrub, no pin)
One `gsap.timeline({ scrollTrigger: { ... } })` with an **empty timeline** — the ScrollTrigger exists purely for its `onUpdate`:
```js
scrollTrigger: {
  trigger: ".intro",
  start: "top bottom",   // fires as soon as the intro enters the viewport
  end: "top 10%",        // completes when the intro's top reaches 10% from the viewport top
  scrub: true,
  onUpdate: (self) => { ... },
}
```
No pinning, no pinSpacing. In `onUpdate`, set `animationState.scrollProgress = self.progress` and interpolate **linearly** with `gsap.utils.interpolate`:
- `currentTranslateY`: `initialTranslateY → 0` (% — the container slides down from above into its natural place).
- `scale`: `0.25 → 1`.
- `gap`: `2 → 1` (em — the gap between video and title tightens as it grows).
- `fontSize` is **two-phase**:
  - `progress ≤ 0.4`: `firstPartProgress = progress / 0.4`; fontSize `80 → 40`.
  - `progress > 0.4`: `secondPartProgress = (progress − 0.4) / 0.6`; fontSize `40 → 20`.
  - (Fast shrink early, slow settle late — at full scale the title reads as a normal 20px caption.)

### 4. Mouse tracking
```js
document.addEventListener("mousemove", (e) => {
  animationState.targetMouseX = (e.clientX / window.innerWidth - 0.5) * 2; // −1 … 1
});
```

### 5. The rAF apply loop (this is what actually moves pixels)
An `animate()` function called via `requestAnimationFrame(animate)` forever (kick it off once). It bails with `return` (stopping the loop) if `window.innerWidth < 900`. Each frame:
```js
const scaledMovementMultiplier = (1 - scale) * movementMultiplier;
const maxHorizontalMovement = scale < 0.95 ? targetMouseX * scaledMovementMultiplier : 0;
animationState.currentMouseX = gsap.utils.interpolate(
  currentMouseX, maxHorizontalMovement, 0.05   // lerp factor 0.05 → soft trailing ease
);
videoContainer.style.transform =
  `translateY(${currentTranslateY}%) translateX(${animationState.currentMouseX}px) scale(${scale})`;
videoContainer.style.gap = `${gap}em`;
videoTitleElements.forEach((el) => { el.style.fontSize = `${fontSize}px`; });
```
Key behaviors this produces:
- **Parallax strength is proportional to how small the video is**: `(1 − scale) × movementMultiplier` px of max travel at each side, so the tiny thumbnail swings widely (up to ±~487px at scale 0.25 on wide screens) and the movement dies to nothing as it grows.
- **Hard gate at `scale ≥ 0.95`**: the target snaps to 0, and the 0.05 lerp glides the video back to center as it reaches full size.
- The transform order must be exactly `translateY(%) translateX(px) scale()`.
- Everything is fully reversible — scrolling back up shrinks the video back into the floating thumbnail.

## Assets / images
- **1 landscape 16:9 image** used as the showreel poster inside the video preview: a cinematic, moody motion-blurred human figure — dark, atmospheric, long-exposure feel, like a frame from a motion-design reel. It fills the rounded 16:9 plate with `object-fit:cover`. (Same image in both the desktop and mobile containers.)

Use the neutral brand name "Motionprompts" — no real studio or client names.

## Behavior notes
- **Desktop-only animation**: below 900px viewport width nothing initializes (no Lenis, no ScrollTrigger, no parallax); the CSS media query swaps in the static `.video-container-mobile` block instead.
- The initial `translateY(-105%) scale(0.25)` in CSS matches the JS default for >1300px, so there's no first-frame jump on wide screens; on narrower desktops the JS breakpoint value takes over on the first rAF tick.
- The scrub has no smoothing of its own (`scrub: true`, not a number) — the floaty feel comes from Lenis inertia plus the 0.05 mouse lerp.
- No SplitText, no CustomEase, no Three.js, no pinning, no reduced-motion branch in the original.
