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…
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
<div class="marquee" data-marquee>
<div class="marquee__track">
<span class="marquee__copy">Brand — Motion — Product — Editorial — </span>
<span class="marquee__copy" aria-hidden="true">Brand — Motion — Product — Editorial — </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
Pairs with
Conflicts with
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.