# Ingamana Scroll Animation

## Goal
Build a long scroll page: a full-screen intro, then a **10-row image grid**, then a full-screen outro. The signature effect: **each grid row continuously widens as you scroll it through the viewport** — from `125%` to `500%` of the viewport width (`250% → 750%` on mobile) — and because every row is horizontally centered inside an `overflow:hidden` container, the extra width bleeds off both edges symmetrically, so the whole row (and its images) reads as a smooth **zoom-in / push-in** the deeper it travels up the screen. Every row runs its own zoom independently, keyed to that row's own scroll progress, producing a staggered cascade of expanding rows. Smooth scroll via Lenis. Crucially, this is **NOT a ScrollTrigger effect** — it is hand-computed every frame from `window.scrollY` inside a `gsap.ticker` callback.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) and `lenis` (npm) for smooth scroll. **No GSAP plugins at all** — no ScrollTrigger, no SplitText, no CustomEase, no Three.js. No framework. Plain Vite-style module imports:
```js
import gsap from "gsap";
import Lenis from "lenis";
```

## Layout / HTML
Three top-level blocks in `<body>`:

```
section.intro                 (full-screen, centered)  > p "Intro Section"
section.projects              (the grid; JS sets its explicit pixel height)
  div.projects-row            × 10   (each row is a horizontal flex strip)
    div.project               × 9    (nine cards per row)
      div.project-img > img
      div.project-info > p (label) + p (year)
section.outro                 (full-screen, centered)  > p "Outro Section"

<script type="module" src="./script.js">   (before </body>)
```

Details that the CSS/JS depend on:
- **Exactly 10 `.projects-row` elements**, each containing **exactly 9 `.project` cards** (90 cards total). The row count and card count both matter — the section-height precompute multiplies by `rows.length`, and 9 flex cards per row set the aspect/height math.
- Each `.project` holds a `.project-img` (the image, cropped) above a `.project-info` bar with two `<p>`: a short **label** on the left and a 4-digit **year** on the right.
- Fill the 90 cards by cycling through 16 distinct images (img1…img16, then repeat). Use neutral one/two-word placeholder labels + years, e.g. `Fieldnotes 2020`, `Redline 2021`, `Gallery Walk 2019`, `Side Profile 2022`, `Open Mic 2023`, `Backboard 2024`, `Afterglow 2021`, `Hill House 2020`, `Low Tide 2018`, `Timepiece 2019`, `Close Focus 2022`, `Airframe 2023`, `Hardcase 2024`, `Deep Red 2021`, `Fast Track 2022`, `Night Shift 2025`. Invent nothing brand-related. Intro/outro paragraphs are literally "Intro Section" / "Outro Section".

## Styling
Import font:
```css
@import url("https://fonts.cdnfonts.com/css/pp-neue-montreal");
```

Global / reset:
- `* { margin:0; padding:0; box-sizing:border-box; }`
- `body { font-family:"PP Neue Montreal", sans-serif; }`
- `img { width:100%; height:100%; object-fit:cover; }` — images always fill their box, cropped.

Typography:
- `p { text-transform:uppercase; font-size:0.9rem; font-weight:500; letter-spacing:-0.02rem; line-height:1; }`
- `.project-info p { font-size:0.75rem; }` (smaller than the intro/outro labels).

Intro & outro (full-screen bookends):
- `.intro, .outro { position:relative; width:100%; height:100svh; display:flex; justify-content:center; align-items:center; overflow:hidden; }`

The grid — **this is the part the effect hangs on**:
- `.projects { position:relative; width:100%; padding:0.5rem 0; display:flex; flex-direction:column; align-items:center; gap:0.5rem; overflow:hidden; }`
  - `align-items:center` + `overflow:hidden` are load-bearing: rows are **wider than the viewport** and centered, so growth spills off both sides equally and is clipped — that symmetric clip is what makes it read as a centered zoom rather than a rightward slide.
  - The `0.5rem` vertical `gap` and `0.5rem 0` `padding` are read back by the JS to compute the section's fixed height (see below), so keep them exactly.
- `.projects-row { width:125%; display:flex; gap:1rem; }` — the **`width:125%` is the animation's start value** (the CSS default before JS takes over); JS overwrites `style.width` every frame. `1rem` horizontal gap between cards.
- `.project { flex:1; aspect-ratio:7/5; display:flex; flex-direction:column; overflow:hidden; }` — nine equal `flex:1` cards; the fixed `7/5` aspect ratio is why a wider row is also a **taller** row (the images grow in both dimensions as the row expands).
- `.project-img { flex:1; min-height:0; overflow:hidden; }` (image area takes all remaining card height above the info bar; `min-height:0` lets it shrink correctly inside the flex column).
- `.project-info { display:flex; justify-content:space-between; padding:0.25rem 0; }` (label left, year right).

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

There is **no gsap tween and no timeline anywhere**. `gsap` is used only for (a) driving Lenis and (b) its high-frequency `ticker` as a rAF loop; all width changes are computed by hand and written to `row.style.width`. Do **not** substitute ScrollTrigger — reproduce the manual `scrollY` math exactly.

Everything runs inside `document.addEventListener("DOMContentLoaded", () => { ... })`.

### 1) Smooth-scroll wiring (Lenis driven by the GSAP ticker)
```js
const lenis = new Lenis({ autoRaf: false });          // Lenis does NOT run its own rAF
gsap.ticker.add((time) => { lenis.raf(time * 1000); }); // GSAP's ticker drives Lenis (time is seconds → ms)
gsap.ticker.lagSmoothing(0);                          // disable lag smoothing so motion stays glued to scroll
```
No `gsap.registerPlugin(...)` call (there are no plugins).

### 2) Cache elements, breakpoint, and the start/end widths
```js
const section = document.querySelector(".projects");
if (!section) return;
const rows = Array.from(section.querySelectorAll(".projects-row")); // the 10 rows

const isMobile = window.innerWidth < 1000;      // breakpoint at 1000px
let rowStartWidth = isMobile ? 250 : 125;       // START width % (mobile 250, desktop 125)
let rowEndWidth   = isMobile ? 750 : 500;       // END   width % (mobile 750, desktop 500)
```
So desktop rows animate `125% → 500%` width; mobile rows animate `250% → 750%`.

### 3) Pre-compute and lock the section's full-expanded height (prevents scroll jump)
Because a row is far taller at `500%` width than at `125%`, letting the grid reflow as rows grow would make the page height (and every row's scroll position) shift mid-scroll. To avoid that, reserve the fully-expanded height up front and pin it as an explicit pixel height on `.projects`:
```js
const firstRow = rows[0];
firstRow.style.width = `${rowEndWidth}%`;          // temporarily expand row 1 to its END width…
const expandedRowHeight = firstRow.offsetHeight;   // …measure how tall a fully-expanded row is…
firstRow.style.width = "";                         // …then revert (CSS 125% start returns)

const sectionGap     = parseFloat(getComputedStyle(section).gap) || 0;        // 0.5rem → 8px
const sectionPadding = parseFloat(getComputedStyle(section).paddingTop) || 0; // 0.5rem → 8px

const expandedSectionHeight =
  expandedRowHeight * rows.length +      // 10 fully-expanded rows
  sectionGap * (rows.length - 1) +       // 9 inter-row gaps
  sectionPadding * 2;                    // top + bottom padding

section.style.height = `${expandedSectionHeight}px`;   // lock the height
```
This makes the total scrollable distance constant, so each row's progress window is stable while its width animates.

### 4) The per-frame zoom (runs every ticker tick)
Add a second callback to the same `gsap.ticker`; it recomputes every row's width from the live `window.scrollY` on every frame:
```js
function onScrollUpdate() {
  const scrollY = window.scrollY;
  const viewportHeight = window.innerHeight;

  rows.forEach((row) => {
    const rect = row.getBoundingClientRect();
    const rowTop = rect.top + scrollY;         // row's absolute top in the document
    const rowBottom = rowTop + rect.height;    // row's absolute bottom

    const scrollStart = rowTop - viewportHeight; // progress 0 when the row's top is one viewport below the scroll
    const scrollEnd   = rowBottom;               // progress 1 when scrollY reaches the row's bottom

    let progress = (scrollY - scrollStart) / (scrollEnd - scrollStart);
    progress = Math.max(0, Math.min(1, progress)); // clamp 0…1

    const width = rowStartWidth + (rowEndWidth - rowStartWidth) * progress; // linear interpolate
    row.style.width = `${width}%`;
  });
}
gsap.ticker.add(onScrollUpdate);
```
Exact behavior to reproduce:
- **Per-row progress window:** for each row, `progress` is `0` while the row's top is still ≥ one full viewport-height below the current scroll (row about to appear from the bottom), and reaches `1` once the page has scrolled such that `scrollY === rowBottom` (row's bottom crossing the very top). Between those two points progress ramps **linearly**.
- **Width mapping:** `width% = rowStartWidth + (rowEndWidth - rowStartWidth) * progress`, i.e. **125% → 500%** desktop (250% → 750% mobile), applied as an inline `width` on the row. Strictly linear — the only smoothing is Lenis's inertial scroll feeding `window.scrollY`.
- **Independent per row:** each row has its own start/end scroll positions, so at any moment different rows sit at different widths — a continuous, staggered cascade of expanding rows, not one synchronized move.
- Because the row is centered (`align-items:center`) inside a clipped container, the growth is symmetric about the horizontal center → centered zoom-in. The `7/5` card aspect ratio means the images enlarge vertically too as the row widens.

### 5) Resize handler (recompute breakpoint widths + locked height)
```js
window.addEventListener("resize", () => {
  const isMobileNow = window.innerWidth < 1000;
  rowStartWidth = isMobileNow ? 250 : 125;
  rowEndWidth   = isMobileNow ? 750 : 500;

  firstRow.style.width = `${rowEndWidth}%`;
  const newRowHeight = firstRow.offsetHeight;
  firstRow.style.width = "";

  const newSectionHeight =
    newRowHeight * rows.length +
    sectionGap * (rows.length - 1) +
    sectionPadding * 2;
  section.style.height = `${newSectionHeight}px`;
});
```
Re-derives the start/end widths for the current breakpoint and re-locks the section height (same formula). It does not otherwise re-run the zoom — the ticker keeps computing widths from the new values on the next frame.

No `ease` keyword, `duration`, `delay`, or `stagger` anywhere — none of GSAP's tweening API is used. The "stagger" look emerges naturally from each row's distinct scroll window; the "ease" is just Lenis smoothing the scroll input.

## Assets / images
**16 distinct images**, landscape framing roughly **7:5** (they render inside `.project-img` at `object-fit:cover`, so exact source ratio is forgiving — they get cropped to fill). The set is an eclectic, editorial photo mix rather than one single subject or palette — each frame is a different subject, color story, and lighting, which is what makes the widening rows read as a curated gallery. Representative frames from the set:
- **Botanical / nature macros** with soft, shallow-depth-of-field backgrounds: pale seedling sprouts lit against a **deep blue-violet** blurred backdrop; a cluster of **purple-and-white bearded irises** with yellow beards shot against a **deep crimson-red** tiled wall; curled, backlit **dried autumn leaves** in warm coppery browns against a near-black background.
- A **product still life** — a **tan/beige suede ankle boot** with a stacked wooden heel, mid-air on a clean **off-white seamless** studio background with a soft drop shadow.
- An **expressive portrait** — a **motion-blurred, open-mouthed screaming face** streaked across a flat **saturated red** field; grainy, raw, high-energy.
- An **interior / architecture** frame — a minimalist boucle lounge chair and small potted plant in a warm-lit, curved **cream plaster** room.

Common thread: clean, intentional photography with strong single-subject focus and generous negative space; dominant colors span deep violet-blue, crimson red, warm amber/brown, and neutral cream/beige. Cycle the 16 across the 90 cards (img1…img16, repeat). No brand marks, logos, or text baked into the images.

## Behavior notes
- **Scroll-scrubbed and fully reversible** — nothing autoplays; every row width is a pure function of the current scroll position, so scrolling back up un-zooms the rows. The ticker recomputes all rows each frame regardless of scroll direction.
- **Breakpoint at 1000px** only swaps the start/end width pair (125/500 → 250/750); the mechanism is identical on mobile.
- Heights use `svh` on the intro/outro so mobile browser chrome doesn't clip the full-screen bookends.
- The section's pixel height is computed once on load and again on resize; the per-frame math reads `getBoundingClientRect()` + `window.scrollY` live, so it self-corrects as the locked height changes.
- No reduced-motion handling in the original.
