# GTA-VI-style Scroll Logo Reveal — Pinned Hero with Shrinking SVG Mask

## Goal

Build a cinematic, full-page landing hero in the style of the GTA VI trailer site: as you scroll, a giant dark overlay punched with a logo-shaped SVG mask hole shrinks exponentially (scale 500 → 1), so the dark screen "closes in" until only a logo-shaped window into the scene remains; meanwhile the layered hero artwork zooms out, a white overlay blooms behind the mask, and a big headline is unveiled with a bottom-up gradient wipe. Everything is driven by a single pinned, scrubbed ScrollTrigger smoothed by Lenis.

## Tech

Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugin `ScrollTrigger`, and `lenis` for smooth scrolling. No other libraries.

## Layout / HTML

```
body
├── section.hero
│   ├── div.hero-img-container
│   │   ├── img                    ← image 1: full-bleed background scene (JPG)
│   │   ├── div.hero-img-logo
│   │   │   └── img                ← image 3: white wordmark logo (transparent PNG)
│   │   ├── img                    ← image 2: foreground subjects cut out (transparent PNG),
│   │   │                             stacked AFTER the logo so the subjects sit in front of it
│   │   └── div.hero-img-copy
│   │       └── p  "Scroll down to reveal"
│   ├── div.fade-overlay
│   ├── div.overlay
│   │   └── svg (width="100%" height="100%")
│   │       ├── defs > mask#logoRevealMask
│   │       │   ├── rect width=100% height=100% fill="white"
│   │       │   └── path#logoMask            ← no fill attribute (defaults to black = hole)
│   │       └── rect width=100% height=100% fill="#111117" mask="url(#logoRevealMask)"
│   ├── div.logo-container             ← empty; geometric reference box for fitting the mask
│   └── div.overlay-copy
│       └── h1  "Animation <br> Experiment 452 <br> By Motionprompts"
└── section.outro
    └── p  "Build your empire. Rule your city."
```

The mask trick: inside `<mask>`, the white rect keeps the dark `#111117` rect visible everywhere, and the black logo `path` punches a transparent hole through it. Scaling the whole `.overlay` element scales the hole.

### Logo mask path data

Keep a `logo.js` module exporting `export const logoData = "M…Z"` — a single SVG path string drawing a chunky, bold two-letter wordmark (e.g. the Roman numerals "VI") as closed polygon subpaths (straight `M/L/Z` commands are fine, one subpath per letter stroke). The exact coordinate system does not matter: a fitting function normalizes the path via `getBBox()` at runtime. Make the letterforms thick and poster-like so the final hole reads clearly.

## Styling

- Google font **DM Sans** (import from Google Fonts). Global reset (`* { margin:0; padding:0; box-sizing:border-box }`).
- `body`: `background: #111117`, `color: #fff`, `overflow-x: hidden`.
- All `img`: `width:100%; height:100%; object-fit:cover`.
- `h1`: uppercase, `font-size: 6rem`, weight 700, `letter-spacing: -0.2rem`, `line-height: 0.8`.
- `p`: uppercase, `font-size: 1.25rem`, weight 500, `line-height: 0.8`.
- Every `section`: `position: relative; width: 100vw; height: 100vh; background-color: #111117; text-align: center; overflow: hidden`.
- `.hero-img-container`, its direct `img`s, and `.fade-overlay`: `position: absolute; top:0; left:0; width:100%; height:100%` (full-bleed stack).
- `.hero-img-logo img`: `position: absolute; top: 25%; left: 50%; transform: translate(-50%, 0); width: 250px; height: auto; object-fit: contain` (overrides the global img rule).
- `.hero-img-copy`: `position: absolute; bottom: 20%; left: 50%; transform: translate(-50%, 0); will-change: opacity`; its `p` is `font-size: 0.65rem`.
- `.fade-overlay`: `background-color: #fff; will-change: opacity;` start it at `opacity: 0` (scroll drives it).
- `.overlay`: `position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 1; transform-origin: center center`. (If you declare it differently in CSS, force these inline from JS on load: `width 100vw / height 100vh / position fixed / top 0 / left 0 / transform none`.)
- `.logo-container`: `position: fixed; top: 25%; left: 50%; transform: translate(-50%, -50%); transform-origin: center center; width: 400px; height: 400px; z-index: 2`. It stays empty — it only defines where/how big the mask hole ends up.
- `.overlay-copy`: `position: absolute; bottom: 25%; left: 50%; transform: translate(-50%, 0); z-index: 2; width: 100%`.
- `.overlay-copy h1`: `background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; color: transparent; transform-origin: center 0%` (the JS paints a gradient into its `background`).
- `.outro`: flex, centered both axes.
- `@media (max-width: 900px)`: `h1 { font-size: 2rem; letter-spacing: 0 }`, `p { font-size: 1rem }`, `.overlay-copy { width: 100% }`.

## GSAP effect (the core — follow exactly)

Everything runs after `DOMContentLoaded`. `gsap.registerPlugin(ScrollTrigger)`.

### Lenis setup

```js
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
```

### Mask fitting (run on load and on resize)

Set the `d` attribute of `path#logoMask` to `logoData`. Then `updateLogoMask()`:

1. `logoDimensions = logoContainer.getBoundingClientRect()`; `logoBoundingBox = logoMask.getBBox()`.
2. `scaleFactor = Math.min(logoDimensions.width / bbox.width, logoDimensions.height / bbox.height)` (contain-fit).
3. Translation centers the scaled path inside the container box, compensating for the bbox origin:
   - `x = logoDimensions.left + (logoDimensions.width − bbox.width × scaleFactor) / 2 − bbox.x × scaleFactor`
   - `y = logoDimensions.top + (logoDimensions.height − bbox.height × scaleFactor) / 2 − bbox.y × scaleFactor`
4. `logoMask.setAttribute("transform", `translate(${x}, ${y}) scale(${scaleFactor})`)`.

### Initial state

```js
const initialOverlayScale = 500;
gsap.set(svgOverlay, {
  transformOrigin: "50% 50%",
  xPercent: 0, yPercent: 0, left: 0, top: 0,
  scale: initialOverlayScale,
});
```

At scale 500 the mask hole is astronomically larger than the viewport, so the dark rect is effectively invisible on load and the hero artwork shows through.

### The single ScrollTrigger

Created by a `setupScrollTrigger()` function that kills any previous instance first (stored in a variable), then:

```js
ScrollTrigger.create({
  trigger: ".hero",
  start: "top top",
  end: `+=${window.innerHeight * 5}px`,   // 5× viewport of scroll distance
  pin: true,
  pinSpacing: true,
  scrub: 1,
  onUpdate: (self) => { /* all animation below, driven by self.progress */ },
});
```

There are **no tweens/timelines** — every value is computed from `progress` (0→1) inside `onUpdate` and applied with `gsap.set`. The phases:

**Phase A — intro fade (progress 0 → 0.15).**
`heroImgLogo` (the `.hero-img-logo` div) and `.hero-img-copy` fade out linearly: `opacity = 1 − progress × (1 / 0.15)`. For `progress > 0.15`, hard-set both to `opacity: 0`.

**Phase B — zoom-out + mask shrink (progress 0 → 0.85).**
Only while `progress <= 0.85`, with `n = progress / 0.85` (normalized 0→1):

- `.hero-img-container` scale: `1.5 − 0.5 × n` — i.e. linear **1.5 → 1.0** (the artwork starts zoomed in and settles).
- `.overlay` scale: **exponential decay** `overlayScale = 500 × (1/500)^n` — 500 → 1 (equal ratio per unit progress, so the shrink feels constant on a log scale rather than slamming shut). Apply with `gsap.set(svgOverlay, { transformOrigin: "50% 25%", scale: overlayScale, force3D: true })` — note the transform origin **switches to `50% 25%`** here so the hole converges on the logo-container position (top 25% of the viewport).
- `.fade-overlay` opacity: `0` until `progress ≥ 0.25`, then `min(1, (progress − 0.25) / 0.4)` — linear **0 → 1 between progress 0.25 and 0.65**. The scene whites out behind the shrinking hole, so the end state reads as a white logo mark on a dark screen.

**Phase C — headline gradient wipe (progress 0.7 → 0.85).**
With `r = (progress − 0.7) / 0.15` (0→1):

- Paint the `h1` background each frame:
  `gradientSpread = 100`; `gradientBottomPosition = 240 − r × 280` (240% → −40%); `gradientTopPosition = gradientBottomPosition − gradientSpread`.
  `h1.style.background = \`linear-gradient(to bottom, #111117 0%, #111117 ${top}%, #e66461 ${bottom}%, #e66461 100%)\`` and `h1.style.backgroundClip = "text"`.
  Because the text is fill-transparent with `background-clip: text`, this sweeps a coral (`#e66461`) fill up through dark (`#111117`) letters, bottom to top.
- `gsap.set(h1, { scale: 1.25 − 0.25 × r, opacity: r })` — scales **1.25 → 1** (transform-origin `center 0%`) while fading **0 → 1**.
- For `progress < 0.7`, keep `h1` at `opacity: 0`.

After progress 0.85 everything holds its final state while the pin plays out, then the page releases into the `.outro` section.

### Resize handling

On `window.resize`: call `updateLogoMask()`, `ScrollTrigger.refresh()`, and `setupScrollTrigger()` again (kill + recreate so the `end` distance tracks the new viewport height).

## Assets / images

Three images (describe roles, no brands):

1. **Background scene** — full-bleed cinematic landscape artwork (~16:9, JPG): a warm sunset/neon-sky vista in a stylized poster look (e.g. characters by a vintage car with palm trees and a pink-purple sky). Bottom layer.
2. **Foreground cut-out** — the same subjects isolated on a transparent background (PNG), pixel-aligned with image 1 so that stacking them recreates the scene with the subjects on a separate layer (they overlap the intro logo).
3. **Wordmark logo** — a white logo/wordmark on a transparent background (PNG), shown centered at the top quarter of the hero before it fades out.

## Behavior notes

- Page-level effect: it owns the scroll (Lenis) and pins the hero for 5 viewport heights; total page = pinned hero + one normal outro screen.
- No autoplay animation — everything is scrubbed; scrolling back up reverses every phase perfectly.
- The mask fit is fully responsive: any logo path works at any viewport because `updateLogoMask()` re-measures on resize.
- Works without WebGL/canvas; keep the dark `#111117` background during load to avoid flashes.
