Infinite Marquee with Scroll Speed

Two bands of huge serif words, one filled and one outlined, loop endlessly in opposite directions; scroll velocity speeds them up and flips them, hovering a band eases it to a stop.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1302 words of exact values — rewritten live as you tune the Playground.

prompt.md · Infinite Marquee with Scroll Speed
### EFFECT: Infinite Marquee with Scroll Speed
**What it does (visual):** After a full-height intro ("Scroll to drive the tape"), the page reaches a band section with two stacked rows of very large light serif words separated by small `#d4ff3a` dots and framed by 1px hairlines. The top row is filled type and glides to the left; the bottom row is outlined italic type (transparent fill, 1px stroke) and glides to the right. Both move at a calm idle pace (90px per second) and never show a seam — the content loops forever. The moment the user scrolls, the rows accelerate in proportion to the scroll velocity (every 600px/s of scroll adds 1×, capped at 4× idle speed) and, if the user scrolls *up*, both rows flip direction. About 150ms after scrolling stops the speed eases back to 1× over 0.6s (keeping whichever direction was last scrolled). Hover pause (true): hovering a row eases that row to a standstill over 0.5s and releases it over 0.6s on leave. A tiny mono readout in the corner shows the live multiplier ("speed ×1.00" → "×3.40"). A normal outro section follows.

**Tech stack:**
- GSAP 3.13+ and ScrollTrigger (only used for `getVelocity()` / `direction`; nothing is pinned or scrubbed).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger);`
- Fonts: a variable serif with an italic (demo uses Fraunces 300 + Fraunces Italic), a mono for the readout.

**HTML structure:**
```html
<section class="mq-intro"><p class="mq-intro__label">Text · Marquee</p><h1 class="mq-intro__title">Scroll to drive the tape</h1><p class="mq-intro__copy">…</p></section>
<section class="mq" aria-label="Marquee">
  <p class="mq__meter" aria-hidden="true">speed <span class="mq__meter-value">×1.00</span></p>
  <div class="mq__row mq__row--fill" data-direction="1">
    <div class="mq__track">
      <div class="mq__set">
        <span class="mq__word">Motion</span><span class="mq__dot" aria-hidden="true"></span>
        <span class="mq__word">Rhythm</span><span class="mq__dot" aria-hidden="true"></span>
        <!-- 4 more word + dot pairs -->
      </div>
    </div>
  </div>
  <div class="mq__row mq__row--outline" data-direction="-1"><div class="mq__track"><div class="mq__set"><!-- 6 word + dot pairs --></div></div></div>
</section>
<section class="mq-outro"><p class="mq-outro__text">…</p></section>
```
JS clones each `.mq__set` once (`aria-hidden="true"` on the clone) and appends it to its `.mq__track`, so every track contains exactly two identical sets. Author the words once; the duplication is runtime.

**CSS requirements:**
- `.mq-intro`, `.mq-outro`: `min-height: 100vh; display: grid; place-content: center; padding: 6vw;` so the page actually scrolls.
- `.mq`: `position: relative; padding: 12vh 0; overflow: hidden;`
- `.mq__row`: `position: relative; border-top: 1px solid var(--line); padding: .12em 0;` last row also gets `border-bottom`. Type on the row: `font-family: var(--font-display); font-weight: 300; font-size: clamp(3.25rem, 11vw, 11rem); line-height: 1; letter-spacing: -0.04em; white-space: nowrap;`
- `.mq__track`: `display: flex; width: max-content; will-change: transform;`
- `.mq__set`: `display: flex; align-items: center; flex: 0 0 auto; padding-right: .35em;` (the padding keeps the gap between the last dot of set 1 and the first word of set 2 equal to every other gap — this is what makes the seam invisible).
- `.mq__word`: `display: inline-block; padding: .04em 0;`
- `.mq__dot`: `display: inline-block; width: .16em; height: .16em; border-radius: 50%; background: var(--accent, #d4ff3a); margin: 0 .38em; flex: 0 0 auto;`
- `.mq__row--outline .mq__word`: `color: transparent; -webkit-text-stroke: 1px color-mix(in srgb, var(--text) 70%, transparent); font-style: italic;` (0.75px stroke under 768px).
- `.mq__meter`: `position: absolute; top: 2.5rem; right: 6vw;` mono 11px uppercase, letter-spacing .14em, muted; `.mq__meter-value { min-width: 5ch; color: var(--accent, #d4ff3a); font-variant-numeric: tabular-nums; }`
- Light theme works automatically through `--bg/--text/--line/--accent`.

**Animation spec (exact values):**
1. Constants: `IDLE_SPEED = 90` (px/s), `MAX_SCALE = 4`, `SETTLE_DELAY = 0.15`, `SETTLE_DURATION = 0.6`, `velocityDivisor = 600` on desktop / two-thirds of that under 768px, `HOVER_PAUSE = true`.
2. Intro: `gsap.from('.mq-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`, `gsap.from('.mq-intro__copy', { y: 24, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.3 })`.
3. After `document.fonts.ready`, for each `.mq__row`: `setWidth = track.firstElementChild.getBoundingClientRect().width`; `dir = Number(row.dataset.direction)`;
   ```js
   const tween = gsap.fromTo(track, { xPercent: 0 }, {
     xPercent: -50, duration: setWidth / IDLE_SPEED, ease: 'none', repeat: -1,
     onReverseComplete() { this.totalTime(this.rawTime() + this.duration() * 100); }
   });
   tween.totalTime(tween.duration() * 100); // headroom so negative timeScale can run backwards indefinitely
   loops.push({ row, tween, dir, hover: 1 });
   ```
   Target `.mq__track`, property `xPercent` `0 → -50`, ease `none`, infinite repeat. Direction is NOT baked into the tween; it comes from `timeScale` sign.
4. Shared state `state = { scale: 1 }`. A `gsap.ticker` callback runs every frame: `loops.forEach(l => l.tween.timeScale(state.scale * l.dir * l.hover))` and writes `'×' + Math.abs(state.scale).toFixed(2)` into `.mq__meter-value`.
5. Scroll coupling — one `ScrollTrigger.create({ onUpdate(self) { … } })` with no trigger (whole page):
   - `v = self.getVelocity()` (px/s), `dir = self.direction || 1` (1 down, −1 up).
   - `target = gsap.utils.clamp(-4, 4, dir * (1 + Math.abs(v) / 600))`
   - `gsap.to(state, { scale: target, duration: 0.25, ease: 'power2.out', overwrite: true })`
   - kill the previous settle call, then `settle = gsap.delayedCall(0.15, () => gsap.to(state, { scale: dir, duration: 0.6, ease: 'power3.out', overwrite: true }))` — so 150ms after the last scroll event the multiplier returns to ±1 over 0.6s.
6. Hover pause (only when `HOVER_PAUSE` is `true` → true and `(hover: hover)` matches): `pointerenter` → `gsap.to(loop, { hover: 0, duration: 0.5, ease: 'power2.out', overwrite: true })`; `pointerleave` → `gsap.to(loop, { hover: 1, duration: 0.6, ease: 'power2.out', overwrite: true })`. The ticker multiplies this factor in, so the row glides to a stop instead of freezing.

**Interaction:** Scrolling is the primary input — scrolling down speeds the rows up in their native directions, scrolling up reverses both and speeds them up; a hard flick hits the 4× ceiling. The tapes never stop while idle. Hovering either row pauses only that row. The mono readout mirrors `|timeScale|` live.

**Responsive behavior:** Font size is fluid via `clamp(3.25rem, 11vw, 11rem)`, so six words plus dots always exceed the viewport width on phones (≥ 2 sets guarantee coverage). Under 768px (`gsap.matchMedia`) the velocity divisor drops to two-thirds of 600 so touch flicks feel as responsive as wheel scrolling, section padding drops to 10vh and the outline stroke to 0.75px. Hover-pause is only bound when `(hover: hover)` matches, so touch devices never get a stuck-paused row.

**Accessibility:** With `prefers-reduced-motion: reduce`, do not create the loops or the ScrollTrigger: both rows render static, the outlined row offset by `xPercent: -25` so the two bands interleave like a poster, and the readout is hidden. The cloned set is `aria-hidden="true"` so screen readers hear each word once; dots are decorative (`aria-hidden`). Section is labelled `aria-label="Marquee"`. The text remains real, selectable text.

**Performance rules:** Only `transform` (`xPercent`) is animated; `will-change: transform` lives on `.mq__track` only. The ticker callback is removed on the `ml:pause` window event (and when the tab is hidden) and re-added on `ml:resume`; GSAP's global timeline pause already freezes the loops. Everything is created inside `gsap.context()` and reverted on unmount (`ctx.revert()` + remove the cloned sets + `ScrollTrigger` kill). Measure widths after `document.fonts.ready` so the duration matches the real glyph widths.

**Customization knobs:**
- `IDLE_SPEED` (90 px/s) — resting pace. 60 feels sleepy, 140 feels busy.
- `MAX_SCALE` (4) and `velocityDivisor` (600, two-thirds under 768px) — how violently scroll affects speed.
- `SETTLE_DELAY` (0.15s) / `SETTLE_DURATION` (0.6s) — how fast the tape relaxes.
- `HOVER_PAUSE` (true) — ease a hovered row to a stop.
- Dot / readout colour (`#d4ff3a`).
- `data-direction` per row (1 / −1) — add a third row with `1` for a three-band tape.
- Font size clamp and `.mq__dot` size — dots scale with the type since they use `em`.

**Acceptance criteria:**
- [ ] Both rows move at idle on load with no visible seam after a full cycle.
- [ ] Scrolling down accelerates both rows; scrolling up reverses both; neither ever exceeds 4× idle.
- [ ] ~0.75s after scrolling stops the speed is back at 1× (meter shows ×1.00).
- [ ] Hovering one row stops only that row; the other continues.
- [ ] Reduced motion: static interleaved rows, no readout, no console errors.
- [ ] Nothing jumps on resize; no horizontal scrollbar on the page (`overflow: hidden` on `.mq`).

Source

Full source code

<section class="mq-intro">
  <p class="mq-intro__label">Text · Marquee</p>
  <h1 class="mq-intro__title">Scroll to drive the tape</h1>
  <p class="mq-intro__copy">Two bands run in opposite directions at an idle pace. Flick the page and they race, scroll back up and they reverse, then they settle again.</p>
</section>

<section class="mq" aria-label="Marquee">
  <p class="mq__meter" aria-hidden="true">speed <span class="mq__meter-value">×1.00</span></p>
  <div class="mq__row mq__row--fill" data-direction="1">
    <div class="mq__track">
      <div class="mq__set">
        <span class="mq__word">Motion</span><span class="mq__dot" aria-hidden="true"></span>
        <span class="mq__word">Rhythm</span><span class="mq__dot" aria-hidden="true"></span>
        <span class="mq__word">Weight</span><span class="mq__dot" aria-hidden="true"></span>
        <span class="mq__word">Tempo</span><span class="mq__dot" aria-hidden="true"></span>
        <span class="mq__word">Craft</span><span class="mq__dot" aria-hidden="true"></span>
        <span class="mq__word">Detail</span><span class="mq__dot" aria-hidden="true"></span>
      </div>
    </div>
  </div>
  <div class="mq__row mq__row--outline" data-direction="-1">
    <div class="mq__track">
      <div class="mq__set">
        <span class="mq__word">Studio</span><span class="mq__dot" aria-hidden="true"></span>
        <span class="mq__word">Archive</span><span class="mq__dot" aria-hidden="true"></span>
        <span class="mq__word">Editorial</span><span class="mq__dot" aria-hidden="true"></span>
        <span class="mq__word">Kinetic</span><span class="mq__dot" aria-hidden="true"></span>
        <span class="mq__word">Type</span><span class="mq__dot" aria-hidden="true"></span>
        <span class="mq__word">Form</span><span class="mq__dot" aria-hidden="true"></span>
      </div>
    </div>
  </div>
</section>

<section class="mq-outro">
  <p class="mq-outro__text">Hover a band to ease it to a stop. Scroll back up and both tapes change direction.</p>
</section>