# ScrollTrigger Variable-Font Marquee — Weight Blooms as the Strips Drift

## Goal

Build an editorial scroll page whose centerpiece is a stack of four horizontal image marquees. As you scroll, each strip of images drifts sideways (alternating rows drift opposite directions), and — interspersed among the thumbnails — big uppercase words are split into individual letters whose **variable font-weight is scrubbed from 100 (hairline) to 900 (black)** in a staggered wave. Everything is scroll-scrubbed through Lenis smooth scroll, so the text visibly "fattens up" and the strips slide as a direct function of scroll position.

## Tech

Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugin `ScrollTrigger`, the `split-type` package (imported as `SplitType`, this is NOT GSAP SplitText), and `lenis` (npm) for smooth scroll. A Vite-style dev server that resolves npm imports is all that's needed.

## Layout / HTML

```
<div class="container">
  <section class="hero">
    <img src="..." alt="" />              <!-- full-bleed opening image -->
  </section>

  <section class="about">
    <p>Step into a surreal, immersive world where reality and fantasy
       intertwine — striking visuals meeting thought-provoking narratives.</p>
  </section>

  <section class="marquees">
    <div class="marquee-container" id="marquee-1">
      <div class="marquee">
        <div class="item"><img src="..." alt="" /></div>
        <div class="item with-text"><h1>Unique</h1></div>
        <div class="item"><img src="..." alt="" /></div>
        <div class="item"><img src="..." alt="" /></div>
        <div class="item"><img src="..." alt="" /></div>
      </div>
    </div>
    <div class="marquee-container" id="marquee-2">
      <div class="marquee">
        <div class="item"><img src="..." alt="" /></div>
        <div class="item"><img src="..." alt="" /></div>
        <div class="item"><img src="..." alt="" /></div>
        <div class="item with-text"><h1>Release</h1></div>
        <div class="item"><img src="..." alt="" /></div>
      </div>
    </div>
    <div class="marquee-container" id="marquee-3">
      <div class="marquee">
        <div class="item"><img src="..." alt="" /></div>
        <div class="item with-text"><h1>2500</h1></div>
        <div class="item"><img src="..." alt="" /></div>
        <div class="item"><img src="..." alt="" /></div>
        <div class="item"><img src="..." alt="" /></div>
      </div>
    </div>
    <div class="marquee-container" id="marquee-4">
      <div class="marquee">
        <div class="item"><img src="..." alt="" /></div>
        <div class="item"><img src="..." alt="" /></div>
        <div class="item"><img src="..." alt="" /></div>
        <div class="item with-text"><h1>Rarity</h1></div>
        <div class="item"><img src="..." alt="" /></div>
      </div>
    </div>
  </section>

  <section class="services">
    <p>In this meticulously designed dystopian world you'll find stories
       of resilience and intrigue.</p>
  </section>

  <section class="footer">
    <h1>The End</h1>
  </section>
</div>
<script type="module" src="./script.js"></script>
```

Key structural facts the JS/CSS depend on: each `.marquee-container` has a unique `id` (`marquee-1`…`marquee-4`), holds exactly one `.marquee` track, and that track holds 5 `.item` children — four `.item` with an `<img>` and one `.item.with-text` with an `<h1>` word. The word position rotates per row: **row 1** word is 2nd (`Unique`), **row 2** word is 4th (`Release`), **row 3** word is 2nd (`2500`), **row 4** word is 4th (`Rarity`).

## Styling

- **Variable font (required for the effect):** load a display sans that exposes a full weight axis (100–900) as a single variable file. The original uses **"Big Shoulders Display"** — available as a variable webfont on Google Fonts, or self-hosted via `@font-face` with a `.ttf`/`.woff2` weight-variable file. Apply it to `html, body` as the global `font-family`. The weight axis MUST be continuous, otherwise the fontWeight tween won't interpolate.
- Global reset: `* { margin: 0; padding: 0; box-sizing: border-box; }`.
- `html, body`: `width: 100%; height: 100%; overflow-x: hidden;` plus the variable font-family.
- `img { width: 100%; height: 100%; object-fit: cover; }`.
- Palette: warm olive-tan `#87795f`, near-black `#13120e`, pure white `#fff`.
- `p`: `font-size: 42px; font-weight: 500; text-transform: uppercase;`.
- Every `section`: `width: 100%; height: 100vh;`.
- `section.about`, `section.services`: `padding: 4em; background: #87795f;`.
- `section.footer`: flex-centered, `background-color: #13120e; color: #87795f;`. Its `h1`: `font-size: 10vw; text-transform: uppercase;`.
- `section.marquees`: `height: 150vh; display: flex; flex-direction: column; justify-content: center; background-color: #fff;` (the white plate the strips live on; taller than one viewport so the strips scroll through it).
- `.marquee-container`: `position: relative; width: 125%; height: 250px; display: flex; gap: 1em; margin-bottom: 1em; overflow: hidden;`. **The 125% width intentionally overflows the viewport** so the strip has slack to slide horizontally; `overflow: hidden` crops it.
- `.marquee`: `width: 100%; height: 100%; position: absolute; top: 50%; left: 0; transform: translateY(-50%); display: flex; gap: 1em;`.
- `#marquee-1 .marquee, #marquee-3 .marquee { left: -15%; }` — rows 1 and 3 start pre-shifted 15% to the left (only these two).
- `.item`: `flex: 1; display: flex; justify-content: center; align-items: center;`.
- `.item.with-text`: `flex: 1.5;` (the word cell is wider than the image cells).
- `.item h1`: `text-transform: uppercase; font-size: 140px;`.
- Include the standard Lenis helper CSS: `html.lenis, html.lenis body { height: auto; }`, `.lenis.lenis-smooth { scroll-behavior: auto !important; }`, `.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }`, `.lenis.lenis-stopped { overflow: hidden; }`, `.lenis.lenis-smooth iframe { pointer-events: none; }`.
- Responsive `@media (max-width: 900px)`: `section.marquees` → `height: 100vh`; `.marquee-container` → `width: 250%; height: 150px`; `#marquee-2 .marquee, #marquee-4 .marquee { left: -35%; }`; `.item.with-text { flex: 1; }`; `.item h1 { font-size: 60px; }`.

## GSAP effect (be exhaustive)

Wrap everything in a `DOMContentLoaded` listener. `gsap.registerPlugin(ScrollTrigger)`.

### 1. Split the words into characters

`const splitText = new SplitType(".item h1", { types: "chars" });` — SplitType wraps every character of every word `<h1>` in a `.char` span (inline-block), which is what lets each letter carry its own animated `fontWeight`. Run this once, before creating the tweens.

### 2. Per-character font-weight bloom (the star effect)

Define a helper:

```
function animateChars(chars, reverse = false) {
  gsap.fromTo(
    chars,
    { fontWeight: 100 },
    {
      fontWeight: 900,
      duration: 1,
      ease: "none",
      stagger: {
        each: 0.35,
        from: reverse ? "start" : "end",
        ease: "linear",
      },
      scrollTrigger: {
        trigger: chars[0].closest(".marquee-container"),
        start: "50% bottom",
        end: "top top",
        scrub: true,
      },
    }
  );
}
```

Details that matter:
- **Animated property:** `fontWeight` `100 → 900` on each `.char`. Because the font's weight axis is continuous, letters visibly thicken from hairline to black.
- **`ease: "none"`** on the tween and **`ease: "linear"`** on the stagger — the weight ramp is perfectly linear; the scrub supplies the feel.
- **`stagger.each: 0.35`** with **`from`** flipping per row creates a directional wave: when `reverse` is `false` the stagger starts `from: "end"` (last letter blooms first, wave travels right-to-left); when `reverse` is `true` it starts `from: "start"` (first letter first, left-to-right).
- **ScrollTrigger:** triggered by the word's own `.marquee-container`, `start: "50% bottom"` (begins when the row's mid-point hits the bottom of the viewport), `end: "top top"` (finishes when the row's top reaches the top), `scrub: true` — so the weight is a direct, reversible function of scroll.

### 3. Horizontal drift of each marquee strip + wiring the char bloom

Iterate every `.marquee-container` with its index:

```
document.querySelectorAll(".marquee-container").forEach((container, index) => {
  let start = "0%";
  let end   = "-15%";
  if (index % 2 === 0) {           // rows 1 & 3 (0-based even)
    start = "0%";
    end   = "10%";
  }

  const marquee = container.querySelector(".marquee");
  const words   = marquee.querySelectorAll(".item h1");

  gsap.fromTo(
    marquee,
    { x: start },
    {
      x: end,
      scrollTrigger: {
        trigger: container,
        start: "top bottom",
        end: "150% top",
        scrub: true,
      },
    }
  );

  words.forEach((word) => {
    const chars = Array.from(word.querySelectorAll(".char"));
    if (chars.length) {
      const reverse = index % 2 !== 0;   // odd rows reverse the bloom
      animateChars(chars, reverse);
    }
  });
});
```

Details that matter:
- **`x` is animated as a percentage string** (`"0%"`, `"10%"`, `"-15%"`) — GSAP interprets this as a percentage of the strip's own width, not pixels. **Even-index rows (1 & 3)** drift `x: "0%" → "10%"` (rightward); **odd-index rows (2 & 4)** drift `x: "0%" → "-15%"` (leftward). Adjacent rows therefore slide in opposite directions.
- Combined with the CSS `left: -15%` on rows 1 & 3, this opposing motion plus the 125%-wide overflowing strips gives the classic contra-scrolling marquee look.
- **ScrollTrigger for the drift:** `trigger: container`, `start: "top bottom"` (begins as the row enters from the bottom), `end: "150% top"` (ends well after it passes), `scrub: true`. Note this drift trigger has a **wider scroll window** than the char-bloom trigger (which runs `"50% bottom" → "top top"`), so the letters finish blooming before the strip finishes drifting.
- The `reverse` flag passed to `animateChars` is tied to row parity, so the bloom wave direction alternates row to row in sync with the drift direction.

### 4. Lenis smooth scroll wired into GSAP's ticker

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

Lenis drives the scroll; ScrollTrigger reads it via the `scroll` event, and Lenis's `raf` is stepped from GSAP's ticker (with `lagSmoothing(0)`). This is what makes the scrubbed weight-blooms and drifts feel buttery.

No timeline, no CustomEase, no Three.js, no pinning. Every animation is an independent scrubbed `fromTo` — the two families of tweens (per-strip `x` drift and per-character `fontWeight` bloom) run in parallel, each governed by its own ScrollTrigger.

## Assets / images

17 images total, all `object-fit: cover`, no logos or brand marks:

- **1 hero image** — a full-bleed, surreal/dystopian, dreamlike 3D-rendered scene filling the opening 100vh viewport (portrait-friendly, but it's cropped to cover).
- **16 marquee thumbnails** — surreal dystopian 3D-rendered stills in the same visual family, distributed 4 per row across the 4 strips. In their 250px-tall cells they read as roughly square-to-portrait crops. They interleave with the four word cells (`Unique`, `Release`, `2500`, `Rarity`) exactly in the positions listed in the Layout section.

## Behavior notes

- Whole page hijacks native scroll via Lenis; all animation is scroll-scrubbed and fully reversible (scrolling up un-blooms the weights and reverses the drift).
- The font-weight bloom depends entirely on a **weight-variable font** — with a static font nothing will interpolate, so verify the variable file loads.
- Alternating drift directions come purely from `index % 2` (rows 1 & 3 vs rows 2 & 4); the bloom-wave direction alternates the same way via the `reverse` flag.
- Responsive: on ≤900px the strips get wider (250%) and shorter (150px), the headline size drops to 60px, and rows 2 & 4 gain a `left: -15%`-equivalent pre-shift (`-35%`); the animation logic is unchanged.
- Desktop-first editorial showcase; no reduced-motion branch in the original.
