# Epic Scroll Story — Rotating Clip-Path Cross that Scales Up to Wipe the Screen

## Goal
Build a long, cinematic single-page scroll story. A tiny white **plus/cross shape** sits over a pinned dark editorial section; as you scroll, it **rotates a full 360°**, its `clip-path` **expands** from a thin cross into a solid white square, it **drifts horizontally** from left-of-center to center, and finally **scales up ~13×** to white-out the entire viewport and hand off to a final white content section. Everything is driven by several scrubbed / `onUpdate` GSAP ScrollTriggers over Lenis-smoothed scrolling, plus two pinned sections.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugin **`ScrollTrigger`**, and **`lenis`** for smooth scroll.

```js
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);
```

Run everything inside a `DOMContentLoaded` handler.

## Layout / HTML
Class names are load-bearing — the JS and CSS query them. Stack these sections inside a single `.container`:

```html
<div class="container">
  <section class="hero">
    <h1>Symphonia</h1>
  </section>

  <section class="info">
    <div class="header-rows">
      <div class="header-row"><h1>Motion</h1></div>
      <div class="header-row"><h1>Stills</h1></div>
    </div>
  </section>

  <section class="header-info">
    <p>Lorem ipsum dolor sit amet consectetur, adipisicing elit. Labore praesentium eaque ipsa illum rem cumque iusto natus, saepe provident quasi?</p>
    <div class="header-images">
      <div class="img"><img src="hero-images/img-1.jpg" alt="" /></div>
      <div class="img"><img src="hero-images/img-2.jpg" alt="" /></div>
      <div class="img"><img src="hero-images/img-3.jpg" alt="" /></div>
      <div class="img"><img src="hero-images/img-4.jpg" alt="" /></div>
    </div>
  </section>

  <section class="whitespace"></section>

  <section class="pinned">
    <div class="revealer">
      <div class="revealer-1"></div>
      <div class="revealer-2"></div>
    </div>
  </section>

  <section class="website-content">
    <h1>Lorem ipsum dolor sit amet consectetur adipisicing elit. Mollitia nobis qui corrupti sit, ad facilis, natus magnam culpa facere sunt pariatur? Voluptatum qui quis sit dolore, dolorum est neque animi!</h1>
  </section>
</div>
```

- Hero title: **"Symphonia"**. Info headers: **"Motion"** (top row) and **"Stills"** (bottom row). Paragraph and final content use placeholder lorem ipsum as above.
- The DOM order matters: `.whitespace` comes **before** `.pinned` in the markup even though `.pinned` is absolutely positioned to overlay earlier sections (see Styling).

## Styling
**Fonts:** Body is **"PP Neue Montreal"** (a clean neutral grotesque sans — any similar grotesque works as fallback). The hero title and the header-info paragraph use **"PP Editorial Old"** (an elegant high-contrast editorial serif — fall back to a display serif). No `@font-face` is required; naming the families with sane fallbacks is fine.

Global:
- `* { margin:0; padding:0; box-sizing:border-box; }`
- `html, body { width:100%; height:100%; font-family:"PP Neue Montreal"; overflow-x:hidden; }`
- `img { width:100%; height:100%; object-fit:cover; }`
- `h1 { text-transform:uppercase; font-size:200px; font-weight:400; letter-spacing:-4px; }` — yes, 200px display headings.

Sections (all full-bleed width):
- `section.hero { width:100vw; height:100vh; background:url(hero-images/hero.jpg) no-repeat 50% 50%; background-size:cover; display:flex; justify-content:center; align-items:center; }` — `.hero h1 { font-family:"PP Editorial Old"; font-weight:lighter; color:#fff; }` (white title over the dark photo).
- `section.info { width:100vw; height:150vh; background:#000; color:#fff; }`. Inside, `.header-row { width:100%; height:250px; padding:0 2em; display:flex; align-items:center; }`; the **1st** row is `justify-content:flex-start` (Motion pinned left), the **2nd** is `justify-content:flex-end` (Stills pinned right).
- `.header-info { position:relative; width:100%; height:100vh; display:flex; flex-direction:column; justify-content:space-between; background:#000; color:#fff; }`. `.header-info p { padding:1em; font-family:"PP Editorial Old"; font-size:52px; font-weight:lighter; }` (paragraph sits at the top). `.header-images { width:100%; height:250px; padding:1em; display:flex; gap:1em; }` (four thumbnails in a row at the bottom); `.img { width:100%; height:100%; }`.
- `section.whitespace { position:relative; width:100%; height:300vh; background:#000; z-index:-1; }` — a tall black spacer that provides scroll runway; sits **behind** everything (`z-index:-1`).
- `section.pinned { position:absolute; top:100vh; width:100%; height:250vh; z-index:2; }` — absolutely positioned so it overlays starting exactly one viewport down (right after the hero), floating above the black sections.
- `section.website-content { position:relative; width:100%; height:150vh; background:#fff; z-index:10; }` — white panel on top of everything. `.website-content h1 { font-size:72px; letter-spacing:0; }` (dark default text on white).

**The revealer (the star element)** — a tiny cross built from two clipped white boxes:
- `.revealer { position:absolute; transform:translate(-50%,0%); left:35%; margin-top:325px; width:120px; height:120px; }` — a 120×120 box, positioned left-of-center (35%) and pushed down 325px inside `.pinned`.
- `.revealer .revealer-1 { position:absolute; inset:0; width:100%; height:100%; background:#fff; clip-path:polygon(45% 0%, 55% 0%, 55% 100%, 45% 100%); }` — a thin **vertical** white bar (only the center 45–55% column shows).
- `.revealer .revealer-2 { position:absolute; inset:0; transform:rotate(90deg); width:100%; height:100%; background:#fff; clip-path:polygon(45% 0%, 55% 0%, 55% 100%, 45% 100%); }` — same clip but **rotated 90°**, so it reads as a thin **horizontal** bar. The two bars together form a small white **plus (+) / cross**.

**Lenis boilerplate CSS** (recommended): `html.lenis, html.lenis body { height:auto; } .lenis.lenis-smooth { scroll-behavior:auto !important; } .lenis.lenis-stopped { overflow:hidden; }`

**Responsive (`@media (max-width:900px)`):** `.hero h1 { font-size:42px; letter-spacing:0; }`; `.header-row { height:100px; }` and `.header-row h1 { font-size:60px; letter-spacing:0; }`; `.header-info p { font-size:24px; }`; `.website-content h1 { font-size:48px; padding:1em; }`; and re-center the revealer: `.revealer { left:50% !important; width:100px; height:100px; margin-top:400px; }`.

## GSAP effect (be exact)

### Lenis ↔ ScrollTrigger wiring
```js
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
```
Lenis drives scrolling; GSAP's ticker drives Lenis (seconds → ms, hence `* 1000`); every Lenis scroll calls `ScrollTrigger.update`.

Then create **six** ScrollTriggers, in this order:

**1. Pin `.pinned`.**
```js
ScrollTrigger.create({
  trigger: ".pinned",
  start: "top top",
  endTrigger: ".whitespace",
  end: "bottom top",
  pin: true,
  pinSpacing: false,
});
```
The `.pinned` section (with the revealer) sticks to the top of the viewport from the moment its top hits the top, until the **bottom of `.whitespace`** reaches the top of the viewport. `pinSpacing:false` so it doesn't add layout space.

**2. Pin `.header-info`.**
```js
ScrollTrigger.create({
  trigger: ".header-info",
  start: "top top",
  endTrigger: ".whitespace",
  end: "bottom top",
  pin: true,
  pinSpacing: false,
});
```
Same end point as the first pin, so the dark editorial block (paragraph + 4 thumbnails) stays frozen behind the revealer across the entire whitespace runway. `pinSpacing:false`.

**3. Rotate the revealer 0° → 360°.**
```js
ScrollTrigger.create({
  trigger: ".pinned",
  start: "top top",
  endTrigger: ".header-info",
  end: "bottom bottom",
  onUpdate: (self) => {
    const rotation = self.progress * 360;
    gsap.to(".revealer", { rotation });
  },
});
```
No `scrub`. On every scroll update it fires a `gsap.to(".revealer", { rotation })` with **GSAP defaults** (duration ≈ 0.5s, ease `power1.out`) — this gives the rotation a slight trailing/catch-up smoothing (unlike the instant tweens below). Progress is measured from `.pinned` top-top to `.header-info` bottom-bottom.

**4. Expand the cross into a solid square via `clip-path`.**
```js
ScrollTrigger.create({
  trigger: ".pinned",
  start: "top top",
  endTrigger: ".header-info",
  end: "bottom bottom",
  onUpdate: (self) => {
    const p = self.progress;
    const clipPath = `polygon(
      ${45 - 45 * p}% ${0}%,
      ${55 + 45 * p}% ${0}%,
      ${55 + 45 * p}% ${100}%,
      ${45 - 45 * p}% ${100}%
    )`;
    gsap.to(".revealer-1, .revealer-2", { clipPath, ease: "none", duration: 0 });
  },
});
```
Same trigger window as #3. As progress 0 → 1, each bar's clip left edge goes **45% → 0%** and right edge **55% → 100%**, so both bars widen from thin strips into the full 120×120 box. Because one bar is rotated 90°, the pair morphs from a small plus into a **filled white square**. `duration:0, ease:"none"` → the clip is bound instantly to scroll (no smoothing lag).

**5. Drift the revealer horizontally `left:35% → 50%` (toward center).**
```js
ScrollTrigger.create({
  trigger: ".header-info",
  start: "top top",
  end: "bottom 50%",
  scrub: 1,
  onUpdate: (self) => {
    const left = 35 + (50 - 35) * self.progress;
    gsap.to(".revealer", { left: `${left}%`, ease: "none", duration: 0 });
  },
});
```
Scrubbed with `scrub:1` (≈1s smoothing). While `.header-info` travels from top-top to bottom-at-50%-of-viewport, the revealer's `left` interpolates 35% → 50%, sliding it to horizontal center. `duration:0, ease:"none"` inside the update, so the smoothing comes purely from `scrub:1`.

**6. Scale the revealer up `1× → 13×` to white-out the screen.**
```js
ScrollTrigger.create({
  trigger: ".whitespace",
  start: "top 50%",
  end: "bottom bottom",
  scrub: 1,
  onUpdate: (self) => {
    const scale = 1 + 12 * self.progress;
    gsap.to(".revealer", { scale, ease: "none", duration: 0 });
  },
});
```
Scrubbed `scrub:1`. Over `.whitespace` (from its top reaching 50% of the viewport to its bottom reaching the bottom), the now-solid white square scales `1 → 13`, ballooning to cover the whole viewport — a white wipe. Because `.website-content` is a white panel at `z-index:10`, the enlarged white square hands off seamlessly into the final content section as it scrolls up.

### Net choreography (top → bottom scroll)
Hero "Symphonia" → dark "Motion / Stills" headers scroll past → `.header-info` and `.pinned` both **pin**; the tiny white cross **spins 360°** while its clip-path **expands into a solid square**, meanwhile **drifting** left→center → then over the whitespace it **scales ~13×** into a full white-out → the white `.website-content` is revealed. All reversible on scroll-up (scrubbed triggers reverse; the two `onUpdate`-driven triggers track progress both ways).

## Assets / images
- **1 hero background:** a dark, atmospheric full-bleed photograph (landscape, cropped with `background-size:cover`) — moody/cinematic, so the white "Symphonia" serif reads clearly over it.
- **4 editorial thumbnails** for the `.header-images` row: matched editorial/photographic images, each cropped into a wide cell (~250px tall, `object-fit:cover`), so roughly landscape 3:2–16:9 crops. They should read as one cohesive editorial set (elegant, muted).

No brand marks or logos — neutral placeholder imagery only. Total **5 images**: `hero.jpg` plus `img-1.jpg … img-4.jpg`.

## Behavior notes
- Page-level component: Lenis hijacks the whole-page scroll for smoothing.
- The tall section heights (`.info` 150vh, `.whitespace` 300vh, `.pinned` 250vh absolute at `top:100vh`) are what create the scroll runway — keep them; the ScrollTrigger start/end/endTrigger relationships depend on this stacking and on `.pinned` being `position:absolute; top:100vh; z-index:2` while `.whitespace` is `z-index:-1` and `.website-content` is `z-index:10`.
- The two pins share the **same end** (`.whitespace` bottom top), so the dark block and the revealer stay locked together through the whole reveal.
- Rotation (trigger #3) is intentionally the only motion using GSAP's default eased `gsap.to` (a soft ~0.5s catch-up); clip-path, left and scale all use `duration:0, ease:"none"` so they bind tightly to scroll (smoothing on #5/#6 comes from `scrub:1`).
- On `max-width:900px` the revealer is re-centered (`left:50%`) and shrunk (100×100, `margin-top:400px`); headings shrink substantially. Effect still works because all revealer transforms are percentage/scale based.
</content>
</invoke>
