# Stacked Alternating Marquees — rows that run opposite ways and lean into the scroll

## Goal

Build a block of **four full-width marquee rows stacked with no gap**, running continuously in
**alternating directions**, with the palette inverting row to row — and with the whole block
**leaning into the scroll**: scrolling speeds each row up in its own direction, and the push
decays when you stop.

Use it for a list of categories or claims that are read at a glance. If the reader has to
actually read and compare the items, this is the wrong mechanic and a list is the right one.

## Tech

Vanilla HTML/CSS/JS with ES modules: `gsap` + `ScrollTrigger`, and `lenis`.

Wire Lenis to ScrollTrigger — this is not optional:

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

Lenis animates its own scroll position; ScrollTrigger reads the native one. Without that first line
the two clocks drift and the triggers fire at the wrong moment or not at all — the component looks
broken in a way that never reaches the console. `lagSmoothing(0)` stops GSAP from swallowing a long
frame, which on a scrubbed mechanic shows up as a jump.

**Do not use a CSS `@keyframes` marquee.** That is the whole reason this is hand-written: a CSS
animation runs on its own clock and cannot know the scroll exists, so the coupling below is
impossible. One `gsap.ticker` loop drives all four rows.

## Structure

```html
<section class="rows">
  <div class="row"     data-dir="1"  data-speed="26"><div class="strip">…</div></div>
  <div class="row inv" data-dir="-1" data-speed="34"><div class="strip">…</div></div>
  <div class="row"     data-dir="1"  data-speed="30"><div class="strip">…</div></div>
  <div class="row inv" data-dir="-1" data-speed="22"><div class="strip">…</div></div>
</section>
```

Each strip holds `<span>` labels and round `<img>` tiles interleaved. Direction and speed live on
the row as data attributes, so re-ordering rows in the HTML re-orders the motion with them.

## The four decisions that ARE this component

### 1. The speeds must NOT be multiples of each other.

`26 / 34 / 30 / 22`. With equal or harmonic speeds the rows re-align every few seconds and the eye
catches the repeat immediately. Near-coprime values keep the pattern from ever settling. This is
the single most noticeable difference between a marquee block that looks alive and one that looks
like a GIF.

### 2. Alternate the direction AND the colour.

Four rows running the same way read as one wide element sliding. Alternating the direction fixes
that — but at a glance the opposition is invisible unless the rows also invert:

```css
.row     { background: var(--cream); color: var(--ink);   }
.row.inv { background: var(--ink);   color: var(--cream); }
```

### 3. The loop wraps at half the MEASURED width, re-measured after images load.

Clone the strip's children once at runtime and wrap the transform at half the total:

```js
const copia = strip.cloneNode(true);
for (const c of [...copia.children]) {
  c.setAttribute("aria-hidden", "true");             // ON THE CHILDREN — see below
  strip.appendChild(c);
}
```

**The `aria-hidden` must go on the appended children, not on the cloned container.** Setting it on
`copia` reads correctly and does nothing at all: only `copia.children` are moved into the strip and
`copia` itself is thrown away, so the attribute never reaches the DOM and a screen reader still
announces every label twice. This component shipped with exactly that bug.

```js

const medir = () => { mitad = strip.scrollWidth / 2; };
medir();
addEventListener("load", medir);
document.fonts?.ready.then(medir);
```

**Re-measuring is not optional.** Images change the strip's width when they load; without it the
wrap point is computed against a width that no longer exists and the loop jumps visibly once per
cycle. Cloning in JS rather than in the markup keeps the HTML readable and keeps the duplicate out
of the accessibility tree.

### 4. The scroll PUSHES; it does not drive.

```js
let empuje = 0;
ScrollTrigger.create({
  trigger: document.body, start: "top top", end: "bottom bottom",
  onUpdate: (s) => { empuje = gsap.utils.clamp(-2.5, 2.5, s.getVelocity() / 900); },
});

gsap.ticker.add((_, dt) => {
  const seg = dt / 1000;
  for (const f of filas) {
    f.x -= f.dir * f.vel * seg * (1 + Math.abs(empuje)) + f.dir * empuje * 60 * seg;
    if (f.x <= -f.mitad) f.x += f.mitad;
    if (f.x >= 0)        f.x -= f.mitad;
    f.strip.style.transform = `translate3d(${f.x}px,0,0)`;
  }
});
gsap.ticker.add(() => { empuje *= 0.92; });   // ← the decay
```

The rows always move; the scroll only adds to it. **The decay is load-bearing**: without
`empuje *= 0.92` per frame, stopping the scroll leaves the rows racing at the last frame's
velocity for a long second, which reads as a bug.

Note this ScrollTrigger uses `document.body` end to end. It is a **global velocity reader**, not a
section animation — do not give it the section as trigger or the rows stop reacting once the block
leaves the viewport.

## Details

- `overflow: hidden` on the **row** (it is genuinely the window the strip runs behind, and it
  contains no sticky) but `overflow-x: clip` on the **section** (the usual reason).
- `will-change: transform` on the strip, `translate3d` in the write: four continuously animating
  full-width elements is exactly the case where the compositor hint pays for itself.
- With `line-height: 0.8` on the hero headline the glyphs **overflow their box** and invade the
  line above. Add `padding-block: 0.1em` or a QC pass will flag it as covered text, correctly.

## Reduced motion

Leave the strips still. The rows still read as a stack of categories, which is the information.
Note the measuring still runs — only the ticker is skipped.

## Adapting

Change the row count (three to five; six starts to read as noise), the labels, the tile shape, the
palette. Keep: non-harmonic speeds, alternating direction *and* colour, the re-measured wrap, the
`aria-hidden` clone, and the decaying scroll push.
