# Sticky Title Bar over Scrolling Logo List — Gap-Spread + Scale Scroll Animation

## Goal

Build a single-page editorial scroll experience. A **fixed, vertically-centered title bar** reading "Barrett & Hale" is painted with `mix-blend-mode: difference` so it inverts against everything scrolling behind it. As each row of a long client-logo list crosses the bar's center line, the row's two logos **spread apart and then snap back together** (animated flex `gap`, driven by scrub ScrollTriggers). At the end of the page a very tall black footer section scrolls in; while it does, the title bar **drifts downward** from vertical-center to near-bottom and its type **scales up massively** (from tiny to huge). The star effect is the scrubbed flex-`gap` spread as rows pass the sticky bar.

## Tech

Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugin **`ScrollTrigger`**. No smooth-scroll library (native browser scroll). No SplitText, no CustomEase, no Three.js, no canvas. Entry: `<script type="module" src="./script.js">`, `import gsap from "gsap"` and `import { ScrollTrigger } from "gsap/ScrollTrigger"`, then `gsap.registerPlugin(ScrollTrigger)` inside a `DOMContentLoaded` handler.

## Layout / HTML

Single `.container` wrapping, in order:

1. `.sticky-bar` — a flex row of three `.item` blocks, each holding a `<p>`:
   - item 1 `<p>Barrett</p>`
   - item 2 `<p>&</p>`
   - item 3 `<p>Hale</p>`
2. `section.hero` — one full-bleed `<img>` (hero image).
3. `section.clients` — **16 `.row` divs, each containing exactly two `.logo` divs**, each `.logo` holding a `<p>` placeholder (`Logo 1` … `Logo 32`). 32 logo cells total.
4. `section.trigger-footer` — one full-bleed `<img>` (footer image).

The class names `.sticky-bar`, `.row`, `.trigger-footer` and the `.sticky-bar p` elements are load-bearing for the JS — keep them exact.

## Styling

- Reset: `* { margin:0; padding:0; box-sizing:border-box }`. `html, body { width:100%; height:100%; overscroll-behavior:none }`.
- Font family: a modern neo-grotesque sans-serif — `font-family: "PP Neue Montreal"` (fall back to `"Neue Montreal", Helvetica, Arial, sans-serif`). All `<p>` are `text-transform: uppercase`, base `font-size: 18px`, `font-weight: 600`.
- `img { width:100%; height:100%; object-fit:cover }`.
- **`.sticky-bar`**: `position: fixed; width:100%; top:50%; transform: translateY(-50%); padding:1em; display:flex; mix-blend-mode: difference;` — this blend mode is essential (the title inverts over the white logo section, the images, and the black footer).
  - Flex distribution: item 1 and item 3 are `flex: 2`; item 2 is `flex: 1` and `text-align:center`; item 3 is `text-align:right`. So the layout is `Barrett` (left) … `&` (center) … `Hale` (right).
  - `.sticky-bar .item p { color:#fff; font-size: 1.25vw; }` (white so `difference` inverts it).
- **`section.hero`**: `width:100vw; height:100vh` (full opening viewport).
- **`.clients`**: `width:100%; padding:10em 1em; background:#fff;`.
- **`.row`**: `width:100%; display:flex; justify-content:center; gap:1em;` (this initial `gap` is what the JS animates).
- **`.logo p`**: `font-size:40px;`.
- **`section.trigger-footer`**: `width:100%; height:300vh; background:#000;` — deliberately 3× viewport tall so the closing animations have a long scrub runway.
- Mobile (`@media (max-width:900px)`): `.sticky-bar .item p { font-size:2.5vw }`, `.logo p { font-size:20px }`.

Color palette: white `#fff` (logo section + title text), black `#000` (footer). No other colors; the imagery supplies the rest.

## GSAP effect (exhaustive)

All animations are **ScrollTrigger `scrub: true`** triggers that **do not** use `gsap.to`/timelines — each one manually mutates inline styles inside its `onUpdate(self)` using `self.progress` (0→1) and linear interpolation `value = start + (end - start) * progress`. There are **four families of triggers**.

Compute a helper once: `getStickyBarCenter() = stickyBar.offsetTop + stickyBar.offsetHeight / 2` (the fixed bar's vertical center in px; ~half the viewport height). Also read `footerTriggerHeight = trigger-footer.offsetHeight` (= 300vh in px). All `start`/`end` values are written as **functions** (`() => \`…\``) so they recompute on refresh/resize.

### 1. Row gap — SPREAD APART (one trigger per `.row`)
For every `.row`:
- `trigger: row`, `scrub: true`
- `start: () => \`top+=${getStickyBarCenter() - 550} center\``
- `end:   () => \`top+=${getStickyBarCenter() - 450} center\``
- `onUpdate`: `maxGap = innerWidth < 900 ? 10 : 15`; `minGap = innerWidth < 900 ? 0.5 : 1`; `currentGap = minGap + (maxGap - minGap) * progress`; set `row.style.gap = \`${currentGap}em\``.
- Net: over this ~100px scroll window the gap grows **1em → 15em** (desktop) / **0.5em → 10em** (mobile) — the two logos push apart as the row approaches the bar's center line.

### 2. Row gap — SNAP BACK TOGETHER (one trigger per `.row`)
For every `.row` (a second, separate trigger):
- `trigger: row`, `scrub: true`
- `start: () => \`top+=${getStickyBarCenter() - 400} center\``
- `end:   () => \`top+=${getStickyBarCenter() - 300} center\``
- `onUpdate`: `maxGap = innerWidth < 900 ? 0.5 : 1`; `minGap = innerWidth < 900 ? 10 : 15`; `currentGap = minGap + (maxGap - minGap) * progress`; set `row.style.gap`.
- Because here `minGap = 15`, `maxGap = 1`, the interpolation runs **15em → 1em** (desktop) / **10em → 0.5em** (mobile) — the logos collapse back together just after the row crosses the bar.

**Combined behavior of #1 + #2:** as a row scrolls up past the fixed center title, its gap goes `1 → 15 → (holds) → 15 → 1` em. The offsets are measured from the row's own top: the point `center−550` above the row hits the viewport center to start the spread, `center−450` ends it (fully spread), then `center−400` starts the collapse and `center−300` finishes it. Between `−450` and `−400` the gap sits at max. Result: each row "opens" as it reaches the bar and "closes" as it leaves — like the title bar splitting the two logos apart while it passes through them.

### 3. Footer — sticky bar DRIFTS DOWN (single trigger)
- `trigger: trigger-footer`, `scrub: true`
- `start: "top bottom"` (footer top reaches viewport bottom)
- `end: () => \`top+=${footerTriggerHeight - window.innerHeight} center\`` (i.e. `2 * innerHeight` below the footer top reaches viewport center)
- `onUpdate`: `newTop = 50 + (92 - 50) * progress`; set `stickyBar.style.top = \`${newTop}%\``.
- Net: the fixed title bar's `top` scrubs **50% → 92%**, sliding it from vertical-center down toward the bottom as the black footer scrolls in.

### 4. Footer — title type SCALES UP (single trigger)
- `trigger: trigger-footer`, `scrub: true`
- `start: () => \`top+=${footerTriggerHeight - (window.innerHeight + 100)} bottom\``
- `end: "bottom bottom"` (footer bottom reaches viewport bottom)
- `onUpdate`: `fontSizeStart = innerWidth < 900 ? 2.5 : 1.25`; `fontSizeEnd = 9`; `newFontSize = fontSizeStart + (fontSizeEnd - fontSizeStart) * progress`; apply to **every `<p>` inside `.sticky-bar`** via `stickyBar.querySelectorAll("p").forEach(p => p.style.fontSize = \`${newFontSize}vw\`)`.
- Net: during the final stretch of the tall footer the title font-size scrubs **1.25vw → 9vw** (desktop) / **2.5vw → 9vw** (mobile) — "Barrett & Hale" balloons into a giant headline, still inverted via `mix-blend-mode: difference` against the black footer (so it reads bright).

**Timing/ordering:** #1 and #2 fire continuously and independently per row throughout the logo section (16 rows → 16 + 16 triggers). #3 and #4 only engage once the tall footer enters/passes. Everything is scrub-linked to scroll position — there is no autoplay, no `duration`, no `ease`, no `stagger`, no `delay`; the "ease" is the linear `progress` interpolation and the browser's scroll.

## Assets / images

Two full-bleed editorial photographs, each meant to fill the viewport (~16:9 landscape / full-screen crop, `object-fit: cover`):
1. **Hero** — full-screen opening image at the top of the page.
2. **Footer** — closing image inside the tall black footer trigger section.
Use neutral, high-contrast editorial photography (fashion/architectural mood works well since the title inverts over it). No brand marks. If only one image is available, reuse it for both slots.

## Behavior notes

- Desktop and mobile both supported; the `<900px` branches only change the gap min/max (10/0.5 vs 15/1) and the title start font-size (2.5vw vs 1.25vw). `mobileSafe`.
- Light performance cost: no WebGL/canvas/physics; only inline-style writes on scroll.
- The effect depends on the footer being **300vh** and the logo section being long (16 rows) — keep those proportions so the scrub windows have room.
- `overscroll-behavior: none` on `html, body` prevents scroll chaining/bounce.
- Wrap all setup in `DOMContentLoaded` and register `ScrollTrigger` before creating triggers so `offsetTop`/`offsetHeight` measurements are valid.
