Rejouice — loading0

Scroll-velocity marquee

A continuously scrolling band of oversized text whose speed and direction respond to how fast the user is scrolling.

Grade: ARuntimes: css · gsapStatus: live
Loading demo…
Scroll-velocity marquee — scroll to change its speedOpen standalone ↗

Intent

A constant-speed marquee is wallpaper. Coupling its velocity to scroll velocity makes the band feel physically connected to the page — it lurches when you flick and drifts when you stop, which turns a decorative element into feedback.

Implementation

The track holds two identical halves; wrapping at half the track width makes the loop seamless without measuring each word.

Dependencies: none

html-css-js
<div class="marquee" data-marquee>
  <div class="marquee__track">
    <span class="marquee__copy">Brand — Motion — Product — Editorial —&nbsp;</span>
    <span class="marquee__copy" aria-hidden="true">Brand — Motion — Product — Editorial —&nbsp;</span>
  </div>
</div>

<style>
.marquee { overflow: hidden; }

.marquee__track {
  display: flex;
  width: max-content;
  will-change: transform;
  transform: translate3d(var(--x, 0px), 0, 0);
}

.marquee__copy {
  font-size: var(--fs-display);
  line-height: 1;
  letter-spacing: -0.03em;
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .marquee__track { transform: none; }
  .marquee__copy + .marquee__copy { display: none; }
}
</style>

<script type="module">
if (!matchMedia("(prefers-reduced-motion: reduce)").matches) {
  for (const root of document.querySelectorAll("[data-marquee]")) {
    const track = root.querySelector(".marquee__track");
    let offset = 0;
    let boost = 0;
    let lastScroll = window.scrollY;

    // Sample scroll in the frame loop; the listener only flags that work is due.
    let pending = false;
    addEventListener("scroll", () => { pending = true; }, { passive: true });

    const half = () => track.scrollWidth / 2;

    const frame = () => {
      if (pending) {
        const y = window.scrollY;
        boost += (y - lastScroll) * 0.12;
        lastScroll = y;
        pending = false;
      }

      boost *= 0.92;
      offset = (offset - 0.6 - boost) % half();
      track.style.setProperty("--x", offset + "px");
      requestAnimationFrame(frame);
    };

    requestAnimationFrame(frame);
  }
}
</script>

Use it when

  • Between sections as a transition band, or as a footer statement.
  • For a short repeated phrase — a service list, a manifesto line.

Avoid it when

  • For content the user actually needs to read. Moving text is not readable text.
  • More than once per page.

Accessibility

  • WCAG 2.2 SC 2.2.2 applies: motion that starts automatically and lasts more than five seconds needs a pause mechanism, or must be disabled for reduced motion. This pattern takes the second route.
  • Mark the duplicate copies `aria-hidden` so the phrase is announced once.
  • Under reduced motion, render a single static copy and stop the loop.

Performance

  • Set `transform` from one rAF loop; never write it from the scroll handler directly.
  • Read `scrollY` in the rAF callback, not in the listener, to avoid forced synchronous layout.
  • Duplicate the phrase in the DOM once at build time rather than cloning on every resize.

Knobs

base speed
Pixels per frame at rest. Default: 0.6
velocity gain
How strongly scroll delta feeds in. Default: 0.12
decay
How quickly the boost eases back to baseline. Default: 0.92

Composition

Instruction for Claude Code

Build a scroll-velocity marquee: a clipped track containing the phrase twice (second copy aria-hidden), advanced by a requestAnimationFrame loop that writes a --x custom property consumed by translate3d. Baseline speed 0.6px/frame; add scroll delta * 0.12 as a boost that decays by 0.92 each frame; wrap the offset with modulo half the track scrollWidth. Read scrollY inside the frame callback, not the passive scroll listener. Under prefers-reduced-motion, skip the loop entirely and hide the duplicate copy.