# Masked Image Reveal Scroll Animation (Pinned Spotlight + Growing SVG Mask)

## Goal
Build a cinematic scroll-story section: after an intro screen, a **spotlight section pins for 7 viewport-heights**. During the first half of the pin, a **300svh-tall grid of desaturated portrait photos scrolls vertically upward** past a fixed centered headline. Overlapping it (progress 0.25 → 0.75), a full-viewport banner image is **revealed through a CSS `mask` shaped like a bold inverted-Y emblem whose `mask-size` grows from 0% to 450%** while the image inside scales down 1.5 → 1. In the final stretch (0.75 → 0.95) a second headline appears **word by word** (hard opacity toggles via SplitText). One `ScrollTrigger` with `onUpdate` drives everything; scroll is smoothed with Lenis.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use **`gsap` (npm)** with the **`ScrollTrigger`** and **`SplitText`** plugins, plus **`lenis`**:
```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import Lenis from "lenis";
```
Everything runs inside a `DOMContentLoaded` listener. Register both plugins, then 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 timelines, no tweens — the entire effect is **one `ScrollTrigger.create()` whose `onUpdate` writes absolute states** with `gsap.set` and direct `style.setProperty` calls.

## Layout / HTML
```html
<body>
  <nav>
    <img src="(logo)" alt="" />
  </nav>

  <section class="intro">
    <div class="header"><h1>Awaken the Scroll</h1></div>
  </section>

  <section class="spotlight">
    <div class="header"><h1>Where Frames Fade Into Fate</h1></div>

    <div class="spotlight-images">
      <div class="row">
        <div class="img"></div>
        <div class="img"><img src="(photo-1)" /></div>
        <div class="img"></div>
        <div class="img"><img src="(photo-2)" /></div>
      </div>
      <div class="row">
        <div class="img"><img src="(photo-3)" /></div>
        <div class="img"></div>
        <div class="img"></div>
        <div class="img"></div>
      </div>
      <div class="row">
        <div class="img"></div>
        <div class="img"><img src="(photo-4)" /></div>
        <div class="img"><img src="(photo-5)" /></div>
        <div class="img"></div>
      </div>
      <div class="row">
        <div class="img"></div>
        <div class="img"><img src="(photo-6)" /></div>
        <div class="img"></div>
        <div class="img"><img src="(photo-7)" /></div>
      </div>
      <div class="row">
        <div class="img"><img src="(photo-8)" /></div>
        <div class="img"></div>
        <div class="img"><img src="(photo-9)" /></div>
        <div class="img"></div>
      </div>
    </div>

    <div class="mask-container">
      <div class="mask-img"><img src="(banner)" alt="" /></div>
      <div class="header"><h1>The Last Frame Hits Hard</h1></div>
    </div>
  </section>

  <section class="outro">
    <h1>End of Act One</h1>
  </section>
  <script type="module" src="./script.js"></script>
</body>
```
5 rows × 4 tiles = 20 `.img` slots, but **only 9 contain an `<img>`** in this exact scatter pattern (row 1: slots 2 & 4 · row 2: slot 1 · row 3: slots 2 & 3 · row 4: slots 2 & 4 · row 5: slots 1 & 3). The empty `.img` divs stay in the flow to preserve the sparse checkerboard rhythm. The JS queries `.spotlight-images`, `.mask-container`, `.mask-img`, and `.mask-container .header h1` — keep those exact.

## Styling
Google Font: **"Barlow Condensed"** (load all weights; 900 is the one used).

- Global reset `* { margin:0; padding:0; box-sizing:border-box; }`; `img { width:100%; height:100%; object-fit:cover; }`.
- `h1 { text-transform:uppercase; font-family:"Barlow Condensed"; font-size:6rem; font-weight:900; line-height:0.85; letter-spacing:-0.02rem; }` — huge condensed poster type.
- `nav`: glassmorphism pill — `position:fixed; top:2rem; left:50%; transform:translateX(-50%); width:35%; padding:1rem 0;` flex-centered, `background:rgba(255,255,255,0.2); backdrop-filter:blur(10px); border:1px solid rgba(255,255,255,0.1); border-radius:0.5rem; z-index:2;`. Its logo img: `width:1.5rem; object-fit:contain;`.
- Every `section`: `position:relative; width:100vw; height:100svh; background:#161616; color:#fff; overflow:hidden;`. `.spotlight` overrides background to **`#101010`**. `.intro`/`.outro` are flex-centered.
- `.header` (used in intro, spotlight, and inside the mask): `position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); text-align:center; width:50%; z-index:1;`.
- `.spotlight-images`: `position:absolute; top:0; left:0; width:100vw; height:300svh;` flex column with `justify-content:space-between;` and **initial CSS state `transform:translateY(5%)`** (must match the JS start value so there's no jump); `will-change:transform;`.
- `.row { width:100%; padding:2rem; display:flex; gap:2rem; }`; `.img { flex:1; aspect-ratio:5/7; overflow:hidden; }`; grid photos are muted: `.img img { opacity:0.5; filter:saturate(0); }`.
- `.mask-container`: `position:absolute; top:0; left:0; width:100vw; height:100svh; overflow:hidden; z-index:10;` with the CSS mask:
  ```css
  -webkit-mask: url(./spotlight-mask.svg) center / contain no-repeat;
  mask: url(./spotlight-mask.svg) center / contain no-repeat;
  -webkit-mask-size: 0%;
  mask-size: 0%;
  ```
  (`center` position is what makes the reveal bloom from the middle as the size grows.) `.mask-img { width:100%; height:100%; }`.
- `@media (max-width:1000px)`: `h1 { font-size:4rem; }`; `nav` and `.header` become `width:calc(100% - 4rem);`; `.spotlight-images { width:200vw; left:-25vw; }` so the tiles keep a chunky portrait size on narrow screens.

### The mask SVG
Create `spotlight-mask.svg` in the project: a single solid-black filled path on a transparent background — a **bold inverted-Y emblem**: a thick vertical stem dropping from the top edge to about half height, then splitting into two thick diagonal legs that spread down to the bottom-left and bottom-right corners (like an upward arrow / bird-foot glyph). Exact geometry:
```svg
<svg width="800" height="693" viewBox="0 0 800 693" fill="none" xmlns="http://www.w3.org/2000/svg">
  <path d="M800 515.749L501.926 343.832V0H297.482V343.832L0 515.749L101.926 693L399.408 521.084L697.482 693L800 515.749Z" fill="black"/>
</svg>
```

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

### Setup measurements (taken once on load)
```js
const spotlightContainerHeight = spotlightImages.offsetHeight; // 300svh in px
const viewportHeight = window.innerHeight;
const initialOffset = spotlightContainerHeight * 0.05;         // the CSS translateY(5%)
const totalMovement = spotlightContainerHeight + initialOffset + viewportHeight;
```

### SplitText on the mask headline
Split `.mask-container .header h1` **by words**:
```js
headerSplit = SplitText.create(maskHeader, { type: "words", wordsClass: "spotlight-word" });
gsap.set(headerSplit.words, { opacity: 0 });
```

### The single ScrollTrigger
```js
ScrollTrigger.create({
  trigger: ".spotlight",
  start: "top top",
  end: `+=${window.innerHeight * 7}px`, // 7 viewport-heights of pinned scroll
  pin: true,
  pinSpacing: true,
  scrub: 1,                             // 1s catch-up smoothing
  onUpdate: (self) => { /* three phases below */ },
});
```
Everything reads `progress = self.progress` and writes absolute values — **no ease, no duration, no delay, no stagger anywhere**; all ramps are linear functions of progress and the floaty feel comes entirely from `scrub: 1` + Lenis inertia. Being scrub-driven, every phase is fully reversible.

**Phase A — image grid scroll-through (progress 0 → 0.5):** only while `progress <= 0.5`:
```js
const imagesMoveProgress = progress / 0.5;
const startY = 5;                                                    // %
const endY = -(totalMovement / spotlightContainerHeight) * 100;      // ≈ −138%
const currentY = startY + (endY - startY) * imagesMoveProgress;
gsap.set(spotlightImages, { y: `${currentY}%` });
```
The 300svh grid starts nudged 5% down and travels linearly up until it has fully exited above the viewport (its own height + the initial offset + one viewport). Past 0.5 nothing is written, so it just stays parked off-screen.

**Phase B — mask reveal (progress 0.25 → 0.75, overlapping phase A):** three branches:
- `0.25 ≤ progress ≤ 0.75`:
  ```js
  const maskProgress = (progress - 0.25) / 0.5;
  const maskSize = `${maskProgress * 450}%`;          // 0% → 450%
  const imageScale = 1.5 - maskProgress * 0.5;        // 1.5 → 1
  maskContainer.style.setProperty("-webkit-mask-size", maskSize);
  maskContainer.style.setProperty("mask-size", maskSize);
  gsap.set(maskImage, { scale: imageScale });
  ```
  The inverted-Y cutout blooms from a point at the center; once `mask-size` passes ~100% the shape outgrows the viewport and the banner becomes effectively fullscreen. Meanwhile the banner image inside settles from an oversized 1.5 to its natural size — a slow push-back that sells the reveal.
- `progress < 0.25`: hard-hold `mask-size: 0%` (both properties) and `scale: 1.5`.
- `progress > 0.75`: hard-hold `mask-size: 450%` and `scale: 1`.

**Phase C — word-by-word headline (progress 0.75 → 0.95):** three branches on the SplitText words:
- `0.75 ≤ progress ≤ 0.95`:
  ```js
  const textProgress = (progress - 0.75) / 0.2;
  headerSplit.words.forEach((word, index) => {
    const wordRevealProgress = index / totalWords;
    gsap.set(word, { opacity: textProgress >= wordRevealProgress ? 1 : 0 });
  });
  ```
  Words pop in sequentially left-to-right as **binary opacity switches (0 or 1)** — a typewriter-like reveal with no fades, no stagger tweens.
- `progress < 0.75`: all words `opacity: 0`.
- `progress > 0.95`: all words `opacity: 1`. The last 5% of the pin holds the finished frame.

## Assets / images
- **9 portrait editorial photographs, 5:7 aspect** — moody, cinematic people/scene shots for the grid tiles. CSS renders them desaturated (`saturate(0)`) at 50% opacity, so tonal contrast matters more than color.
- **1 wide cinematic banner photograph, landscape (≥16:9, full-viewport cover)** — the hero image revealed through the mask; it must hold up fullscreen.
- **1 small monochrome logo mark, square-ish, transparent background** (~1.5rem display size) for the glass nav pill.
- **1 SVG mask file** (`spotlight-mask.svg`) — created inline per the Styling section, not a photo.

Keep all copy neutral (the demo headlines above) — no real brand names.

## Behavior notes
- Total experience: intro (1 viewport) → spotlight pinned for `7 × innerHeight` of scroll → outro (1 viewport). `pinSpacing: true` provides the scroll runway.
- Choreography overlap is the signature: the grid is still rushing upward (phase A ends at 0.5) while the mask is already blooming (phase B starts at 0.25) — the reveal punches through the moving grid. `.mask-container` sits at `z-index:10`, so as the mask grows it covers both the grid and the "Where Frames Fade Into Fate" headline behind it.
- The centered spotlight headline never animates — it's a fixed backdrop the grid scrolls past.
- Measurements are captured once on `DOMContentLoaded`; no resize handler in the original.
- Sections use `100svh` so mobile browser chrome doesn't clip; the effect runs unchanged on mobile (≤1000px only re-sizes type, widens nav/headers, and lets the grid bleed to 200vw).
- No CustomEase, no Three.js, no lerp/rAF loop beyond the standard Lenis ticker, no reduced-motion branch in the original.
