All components

Stacked Alternating Marquees

GSAP animation component · Published 2026-08-02 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Four full-width marquee rows stacked with no gap, running continuously in ALTERNATING directions at non-harmonic speeds (26/34/30/22), each row inverting the palette so the opposition is visible at a glance. The rows also lean into the scroll: a single ScrollTrigger reads `getVelocity()` and feeds a push term that speeds each row up in its own direction, decaying at 0.92 per frame so stopping does not leave them racing. Written as one `gsap.ticker` loop rather than CSS `@keyframes` precisely because a CSS animation runs on its own clock and cannot know the scroll exists. The strip content is cloned once at runtime (the copy `aria-hidden`, so categories are not read twice) and the transform wraps at exactly half the measured width, re-measured on `load` and on `fonts.ready` because images change that width. Dressed as a print fair: Archivo Black + Syne Mono, teal, cream and red.

How it's built

Categoryscroll
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitysection
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

marquee ticker infinite scroll scrolltrigger velocity alternating rows typography lenis bold

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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:

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

<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:

.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:

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.


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.
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.