# Scroll-Scaling Editorial Image Grid

## Goal

Build a full-page scroll gallery: a dark editorial grid of portraits arranged in 10 four-column rows, where each row's images **scale up from 0 to 1** (from a corner transform-origin) as the row scrolls into view, then the row **pins** and its images **scale back down from 1 to 0** as it scrolls out — all driven by scrubbed ScrollTriggers and Lenis smooth scrolling.

## Tech

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

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

Run everything inside a `DOMContentLoaded` listener.

## Layout / HTML

Three sections in `<body>`:

1. `<section class="intro">` — an `<h1>` reading "Design that Captivates" and a `<p>` reading "( Explore Below )".
2. `<section class="work">` — **10** `<div class="row">` elements. Every row contains exactly **4** `<div class="col">` elements. Some cols are empty; others contain one `<div class="img" data-origin="left|right"><img src="..." alt="" /></div>`. There are **17 images total**, distributed like this (cols listed 1→4, `L` = `data-origin="left"`, `R` = `data-origin="right"`, `—` = empty col):
   - Row 1: `R` (img 1), `—`, `L` (img 2), `—`
   - Row 2: `—`, `L` (img 3), `—`, `—`
   - Row 3: `R` (img 4), `—`, `—`, `L` (img 5)
   - Row 4: `—`, `L` (img 6), `R` (img 7), `—`
   - Row 5: `L` (img 8), `—`, `—`, `L` (img 9)
   - Row 6: `—`, `—`, `L` (img 10), `—`
   - Row 7: `—`, `L` (img 11), `—`, `L` (img 12)
   - Row 8: `R` (img 13), `—`, `L` (img 14), `—`
   - Row 9: `—`, `L` (img 15), `—`, `—`
   - Row 10: `R` (img 16), `—`, `—`, `L` (img 17)
3. `<section class="outro">` — a single `<p>` reading "( Return to the Beginning )".

## Styling

- Universal reset: `* { margin: 0; padding: 0; box-sizing: border-box; }`.
- `body`: `font-family: "TWK Lausanne", monospace;` (a neutral grotesque sans; monospace fallback is fine).
- `img`: `width: 100%; height: 100%; object-fit: cover;`.
- `h1`: `text-transform: uppercase; text-align: center; font-size: 10vw; font-weight: 400;`.
- `p`: `text-transform: uppercase; font-family: "Akkurat Mono"; font-size: 13px;` (small mono caption style).
- `.intro, .outro`: `position: relative; width: 100vw; height: 100vh; background-color: #101214; color: #fff;` flex column, centered both axes, `gap: 4em; overflow: hidden;`.
- `.work`: `position: relative; width: 100vw; overflow: hidden; background-color: #1a1d20;` (slightly lighter charcoal than intro/outro).
- `.row`: `width: 100vw; display: flex;`.
- `.col`: `flex: 1; aspect-ratio: 1;` — so each col is a 25vw × 25vw square and each row is 25vw tall.
- `.img`: `position: relative; width: 100%; height: 100%; will-change: transform;`.
- Transform origins are critical for the effect (images grow out of a top corner):
  - `.img[data-origin="left"] { transform-origin: 0% 0%; }`
  - `.img[data-origin="right"] { transform-origin: 100% 0%; }`

## GSAP effect (be precise)

### 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);
```

### Setup

- Safety pass: for every `.img` **without** a `data-origin` attribute, assign one alternating by index — `index % 2 === 0` → `"left"`, otherwise `"right"`.
- Initial state: `gsap.set(".img", { scale: 0, force3D: true });` — all images start invisible at scale 0.

### Per-row ScrollTriggers

Loop over all `.row` elements (`rows.forEach((row, index) => ...)`). Skip rows with no `.img` children. For each qualifying row, set `row.id = "row-" + index` and create **three** ScrollTriggers (give them ids so they can look each other up):

**1. Scale-in trigger** (`id: "scaleIn-" + index`):
- `trigger: row`, `start: "top bottom"`, `end: "bottom bottom-=10%"`, `scrub: 1`, `invalidateOnRefresh: true`.
- No tween — drive it manually in `onUpdate(self)`: only when `self.isActive`, compute
  ```js
  const easedProgress = Math.min(1, self.progress * 1.2);
  const scaleValue = gsap.utils.interpolate(0, 1, easedProgress);
  ```
  and `gsap.set` every image in the row to `{ scale: scaleValue, force3D: true }`. This 1.2 multiplier makes images reach full size at 83% of the trigger distance. Additionally, when `self.progress > 0.95`, hard-snap the row's images to `{ scale: 1, force3D: true }`.
- `onLeave`: snap the row's images to `{ scale: 1, force3D: true }`.

**2. Scale-out trigger with pin** (`id: "scaleOut-" + index`):
- `trigger: row`, `start: "top top"`, `end: "bottom top"`, `pin: true`, `pinSpacing: false`, `scrub: 1`, `invalidateOnRefresh: true`. (With `pinSpacing: false` each row pins at the top of the viewport while the next row slides up over it.)
- `onEnter`: set the row's images to `{ scale: 1, force3D: true }`.
- `onUpdate(self)`:
  - If `self.isActive`: `const scale = gsap.utils.interpolate(1, 0, self.progress);` and `gsap.set` each image to `{ scale, force3D: true, clearProps: self.progress === 1 ? "scale" : "" }` (clear the inline scale only when fully done).
  - Else: if `self.scroll() < self.start` (viewport is above the row), reset the images to `{ scale: 1, force3D: true }`.

**3. Marker/guard trigger** (`id: "marker-" + index`):
- `trigger: row`, `start: "bottom bottom"`, `end: "top top"`, no scrub, no pin.
- In `onEnter`, `onLeave`, and `onEnterBack` (same body in all three): look up the scale-out trigger via `ScrollTrigger.getById("scaleOut-" + index)`; if it exists and its `progress === 0`, force the row's images to `{ scale: 1, force3D: true }`. This guard keeps rows fully visible in the zone between "finished scaling in" and "started scaling out", including when scrolling back up.

### Resize

```js
window.addEventListener("resize", () => { ScrollTrigger.refresh(true); });
```

## Assets / images

**17 moody editorial fashion portraits** (a mix of color — deep reds, teals, warm skin tones — and high-contrast black-and-white studio shots: close-up faces, silhouettes, full-body figures in sculptural garments). Any aspect ratio works because each is cropped into a **1:1 square** cell via `object-fit: cover`; portrait-oriented (2:3-ish) crops look best. Name them `img1.jpeg` … `img17.jpeg` and place them in the HTML order given in the layout table above.

## Behavior notes

- This is a page-level component: Lenis takes over scrolling for the whole page.
- The net choreography: images of a row grow from 0→1 out of their top-left or top-right corner while the row travels up the viewport, the row then sticks to the top of the screen (pinned, no pin spacing) and its images shrink 1→0 as the following row covers it — producing a continuous bloom-and-collapse rhythm through all 10 rows.
- The intro and outro sections have no animation; they simply bookend the grid with full-viewport dark panels.
- All scaling is done through `gsap.set` inside `onUpdate` callbacks (not tweens), so motion smoothness comes entirely from `scrub: 1` + Lenis interpolation.
- Use `force3D: true` on every set to keep the transforms on the GPU; `will-change: transform` is already on `.img`.
