Scroll-Velocity Reactive Marquee — Speed & Skew Follow the Page
The award-site flourish: a giant outlined-text marquee that idles at a lazy drift, then accelerates, skews and even reverses with the user's scroll — scroll down fast and the words whip left with a speed-lean; flick up and they swing back. One rAF loop, one scroll listener, and lerp smoothing doing all the choreography.
Published
The code
<section class="mqs-12" aria-label="Scroll velocity marquee demo">
<p class="mqs-12__hint">Scroll this page — fast, slow, then back up ↑</p>
<div class="mqs-12__stage">
<div class="mqs-12__viewport">
<div class="mqs-12__track" id="mqs-12-track">
<span class="mqs-12__word">Velocity</span><span class="mqs-12__word mqs-12__word--ghost">Motion</span><span class="mqs-12__word">Design</span><span class="mqs-12__word mqs-12__word--ghost">Craft</span>
<span class="mqs-12__word" aria-hidden="true">Velocity</span><span class="mqs-12__word mqs-12__word--ghost" aria-hidden="true">Motion</span><span class="mqs-12__word" aria-hidden="true">Design</span><span class="mqs-12__word mqs-12__word--ghost" aria-hidden="true">Craft</span>
</div>
</div>
</div>
<p class="mqs-12__hint">Notice it reverses when you scroll up</p>
</section><section class="mqs-12" aria-label="Scroll velocity marquee demo">
<p class="mqs-12__hint">Scroll this page — fast, slow, then back up ↑</p>
<div class="mqs-12__stage">
<div class="mqs-12__viewport">
<div class="mqs-12__track" id="mqs-12-track">
<span class="mqs-12__word">Velocity</span><span class="mqs-12__word mqs-12__word--ghost">Motion</span><span class="mqs-12__word">Design</span><span class="mqs-12__word mqs-12__word--ghost">Craft</span>
<span class="mqs-12__word" aria-hidden="true">Velocity</span><span class="mqs-12__word mqs-12__word--ghost" aria-hidden="true">Motion</span><span class="mqs-12__word" aria-hidden="true">Design</span><span class="mqs-12__word mqs-12__word--ghost" aria-hidden="true">Craft</span>
</div>
</div>
</div>
<p class="mqs-12__hint">Notice it reverses when you scroll up</p>
</section>.mqs-12,
.mqs-12 *,
.mqs-12 *::before,
.mqs-12 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.mqs-12 {
width: 100%;
min-height: 100vh;
--gap: 48px;
font-family: 'Segoe UI',system-ui,sans-serif;
background: #101014;
padding-block: 0 200vh;
}
.mqs-12__hint {
text-align: center;
font-size: 12px;
letter-spacing: .18em;
text-transform: uppercase;
color: #52525b;
padding-block: 24px;
}
.mqs-12__stage {
position: sticky;
top: 15vh;
padding-block: 30px;
border-block: 1px solid #27272a;
background: #101014;
}
.mqs-12__viewport {
overflow: hidden;
}
.mqs-12__track {
display: flex;
align-items: baseline;
gap: var(--gap);
width: max-content;
will-change: transform;
}
.mqs-12__word {
flex: none;
font-size: clamp(56px,9vw,120px);
font-weight: 800;
letter-spacing: -.04em;
line-height: 1;
color: #f4f4f5;
}
.mqs-12__word--ghost {
color: transparent;
-webkit-text-stroke: 2px #3f3f46;
}
@media (prefers-reduced-motion: reduce) {
.mqs-12 {
width: 100%;
padding-block: 64px;
}
}.mqs-12,
.mqs-12 *,
.mqs-12 *::before,
.mqs-12 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.mqs-12 {
width: 100%;
min-height: 100vh;
--gap: 48px;
font-family: 'Segoe UI',system-ui,sans-serif;
background: #101014;
padding-block: 0 200vh;
}
.mqs-12__hint {
text-align: center;
font-size: 12px;
letter-spacing: .18em;
text-transform: uppercase;
color: #52525b;
padding-block: 24px;
}
.mqs-12__stage {
position: sticky;
top: 15vh;
padding-block: 30px;
border-block: 1px solid #27272a;
background: #101014;
}
.mqs-12__viewport {
overflow: hidden;
}
.mqs-12__track {
display: flex;
align-items: baseline;
gap: var(--gap);
width: max-content;
will-change: transform;
}
.mqs-12__word {
flex: none;
font-size: clamp(56px,9vw,120px);
font-weight: 800;
letter-spacing: -.04em;
line-height: 1;
color: #f4f4f5;
}
.mqs-12__word--ghost {
color: transparent;
-webkit-text-stroke: 2px #3f3f46;
}
@media (prefers-reduced-motion: reduce) {
.mqs-12 {
width: 100%;
padding-block: 64px;
}
}// The scroll listener only RECORDS velocity; rAF does all the writing (never write transforms in a scroll handler).
const track = document.getElementById('mqs-12-track');
const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;
const BASE = 60; // idle drift px/s
const FACTOR = 0.25; // how hard page scroll drives the marquee
let x = 0, half = 0, velocity = 0, smooth = 0;
let lastScroll = window.scrollY, prev = performance.now();
const measure = () => { half = (track.scrollWidth + parseFloat(getComputedStyle(track).gap)) / 2; };
new ResizeObserver(measure).observe(track); measure();
if (!REDUCED) {
window.addEventListener('scroll', () => {
velocity += (window.scrollY - lastScroll) * 12; // signed: up-scroll reverses the marquee
lastScroll = window.scrollY;
}, { passive: true });
requestAnimationFrame(function frame(now) {
const dt = Math.min((now - prev) / 1000, 0.05); prev = now;
velocity *= 0.9; // decay: effect relaxes when scrolling stops
smooth += (velocity - smooth) * 0.08; // lerp: spiky wheel events become liquid motion
x += (BASE + smooth * FACTOR) * dt;
if (half > 0) x = ((x % half) + half) % half; // modulo wrap = no seam at any speed
const skew = Math.max(-12, Math.min(12, smooth * -0.02));
track.style.transform = 'translate3d(' + (-x) + 'px,0,0) skewX(' + skew + 'deg)';
requestAnimationFrame(frame);
});
}// The scroll listener only RECORDS velocity; rAF does all the writing (never write transforms in a scroll handler).
const track = document.getElementById('mqs-12-track');
const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;
const BASE = 60; // idle drift px/s
const FACTOR = 0.25; // how hard page scroll drives the marquee
let x = 0, half = 0, velocity = 0, smooth = 0;
let lastScroll = window.scrollY, prev = performance.now();
const measure = () => { half = (track.scrollWidth + parseFloat(getComputedStyle(track).gap)) / 2; };
new ResizeObserver(measure).observe(track); measure();
if (!REDUCED) {
window.addEventListener('scroll', () => {
velocity += (window.scrollY - lastScroll) * 12; // signed: up-scroll reverses the marquee
lastScroll = window.scrollY;
}, { passive: true });
requestAnimationFrame(function frame(now) {
const dt = Math.min((now - prev) / 1000, 0.05); prev = now;
velocity *= 0.9; // decay: effect relaxes when scrolling stops
smooth += (velocity - smooth) * 0.08; // lerp: spiky wheel events become liquid motion
x += (BASE + smooth * FACTOR) * dt;
if (half > 0) x = ((x % half) + half) % half; // modulo wrap = no seam at any speed
const skew = Math.max(-12, Math.min(12, smooth * -0.02));
track.style.transform = 'translate3d(' + (-x) + 'px,0,0) skewX(' + skew + 'deg)';
requestAnimationFrame(frame);
});
}Here's a working CSS Infinite Marquee Design from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Scroll-Velocity Reactive Marquee — Speed & Skew Follow the Page
Source: https://codefronts.com/motion/css-infinite-marquee/scroll-velocity-reactive-marquee/
The award-site flourish: a giant outlined-text marquee that idles at a lazy drift, then accelerates, skews and even reverses with the user's scroll — scroll down fast and the words whip left with a speed-lean; flick up and they swing back. One rAF loop, one scroll listener, and lerp smoothing doing all the choreography.
## HTML
```html
<section class="mqs-12" aria-label="Scroll velocity marquee demo">
<p class="mqs-12__hint">Scroll this page — fast, slow, then back up ↑</p>
<div class="mqs-12__stage">
<div class="mqs-12__viewport">
<div class="mqs-12__track" id="mqs-12-track">
<span class="mqs-12__word">Velocity</span><span class="mqs-12__word mqs-12__word--ghost">Motion</span><span class="mqs-12__word">Design</span><span class="mqs-12__word mqs-12__word--ghost">Craft</span>
<span class="mqs-12__word" aria-hidden="true">Velocity</span><span class="mqs-12__word mqs-12__word--ghost" aria-hidden="true">Motion</span><span class="mqs-12__word" aria-hidden="true">Design</span><span class="mqs-12__word mqs-12__word--ghost" aria-hidden="true">Craft</span>
</div>
</div>
</div>
<p class="mqs-12__hint">Notice it reverses when you scroll up</p>
</section>
```
## CSS
```css
.mqs-12,
.mqs-12 *,
.mqs-12 *::before,
.mqs-12 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.mqs-12 {
width: 100%;
min-height: 100vh;
--gap: 48px;
font-family: 'Segoe UI',system-ui,sans-serif;
background: #101014;
padding-block: 0 200vh;
}
.mqs-12__hint {
text-align: center;
font-size: 12px;
letter-spacing: .18em;
text-transform: uppercase;
color: #52525b;
padding-block: 24px;
}
.mqs-12__stage {
position: sticky;
top: 15vh;
padding-block: 30px;
border-block: 1px solid #27272a;
background: #101014;
}
.mqs-12__viewport {
overflow: hidden;
}
.mqs-12__track {
display: flex;
align-items: baseline;
gap: var(--gap);
width: max-content;
will-change: transform;
}
.mqs-12__word {
flex: none;
font-size: clamp(56px,9vw,120px);
font-weight: 800;
letter-spacing: -.04em;
line-height: 1;
color: #f4f4f5;
}
.mqs-12__word--ghost {
color: transparent;
-webkit-text-stroke: 2px #3f3f46;
}
@media (prefers-reduced-motion: reduce) {
.mqs-12 {
width: 100%;
padding-block: 64px;
}
}
```
## JavaScript
```js
// The scroll listener only RECORDS velocity; rAF does all the writing (never write transforms in a scroll handler).
const track = document.getElementById('mqs-12-track');
const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;
const BASE = 60; // idle drift px/s
const FACTOR = 0.25; // how hard page scroll drives the marquee
let x = 0, half = 0, velocity = 0, smooth = 0;
let lastScroll = window.scrollY, prev = performance.now();
const measure = () => { half = (track.scrollWidth + parseFloat(getComputedStyle(track).gap)) / 2; };
new ResizeObserver(measure).observe(track); measure();
if (!REDUCED) {
window.addEventListener('scroll', () => {
velocity += (window.scrollY - lastScroll) * 12; // signed: up-scroll reverses the marquee
lastScroll = window.scrollY;
}, { passive: true });
requestAnimationFrame(function frame(now) {
const dt = Math.min((now - prev) / 1000, 0.05); prev = now;
velocity *= 0.9; // decay: effect relaxes when scrolling stops
smooth += (velocity - smooth) * 0.08; // lerp: spiky wheel events become liquid motion
x += (BASE + smooth * FACTOR) * dt;
if (half > 0) x = ((x % half) + half) % half; // modulo wrap = no seam at any speed
const skew = Math.max(-12, Math.min(12, smooth * -0.02));
track.style.transform = 'translate3d(' + (-x) + 'px,0,0) skewX(' + skew + 'deg)';
requestAnimationFrame(frame);
});
}
```Here's a working CSS Infinite Marquee Design from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Scroll-Velocity Reactive Marquee — Speed & Skew Follow the Page
Source: https://codefronts.com/motion/css-infinite-marquee/scroll-velocity-reactive-marquee/
The award-site flourish: a giant outlined-text marquee that idles at a lazy drift, then accelerates, skews and even reverses with the user's scroll — scroll down fast and the words whip left with a speed-lean; flick up and they swing back. One rAF loop, one scroll listener, and lerp smoothing doing all the choreography.
## HTML
```html
<section class="mqs-12" aria-label="Scroll velocity marquee demo">
<p class="mqs-12__hint">Scroll this page — fast, slow, then back up ↑</p>
<div class="mqs-12__stage">
<div class="mqs-12__viewport">
<div class="mqs-12__track" id="mqs-12-track">
<span class="mqs-12__word">Velocity</span><span class="mqs-12__word mqs-12__word--ghost">Motion</span><span class="mqs-12__word">Design</span><span class="mqs-12__word mqs-12__word--ghost">Craft</span>
<span class="mqs-12__word" aria-hidden="true">Velocity</span><span class="mqs-12__word mqs-12__word--ghost" aria-hidden="true">Motion</span><span class="mqs-12__word" aria-hidden="true">Design</span><span class="mqs-12__word mqs-12__word--ghost" aria-hidden="true">Craft</span>
</div>
</div>
</div>
<p class="mqs-12__hint">Notice it reverses when you scroll up</p>
</section>
```
## CSS
```css
.mqs-12,
.mqs-12 *,
.mqs-12 *::before,
.mqs-12 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.mqs-12 {
width: 100%;
min-height: 100vh;
--gap: 48px;
font-family: 'Segoe UI',system-ui,sans-serif;
background: #101014;
padding-block: 0 200vh;
}
.mqs-12__hint {
text-align: center;
font-size: 12px;
letter-spacing: .18em;
text-transform: uppercase;
color: #52525b;
padding-block: 24px;
}
.mqs-12__stage {
position: sticky;
top: 15vh;
padding-block: 30px;
border-block: 1px solid #27272a;
background: #101014;
}
.mqs-12__viewport {
overflow: hidden;
}
.mqs-12__track {
display: flex;
align-items: baseline;
gap: var(--gap);
width: max-content;
will-change: transform;
}
.mqs-12__word {
flex: none;
font-size: clamp(56px,9vw,120px);
font-weight: 800;
letter-spacing: -.04em;
line-height: 1;
color: #f4f4f5;
}
.mqs-12__word--ghost {
color: transparent;
-webkit-text-stroke: 2px #3f3f46;
}
@media (prefers-reduced-motion: reduce) {
.mqs-12 {
width: 100%;
padding-block: 64px;
}
}
```
## JavaScript
```js
// The scroll listener only RECORDS velocity; rAF does all the writing (never write transforms in a scroll handler).
const track = document.getElementById('mqs-12-track');
const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;
const BASE = 60; // idle drift px/s
const FACTOR = 0.25; // how hard page scroll drives the marquee
let x = 0, half = 0, velocity = 0, smooth = 0;
let lastScroll = window.scrollY, prev = performance.now();
const measure = () => { half = (track.scrollWidth + parseFloat(getComputedStyle(track).gap)) / 2; };
new ResizeObserver(measure).observe(track); measure();
if (!REDUCED) {
window.addEventListener('scroll', () => {
velocity += (window.scrollY - lastScroll) * 12; // signed: up-scroll reverses the marquee
lastScroll = window.scrollY;
}, { passive: true });
requestAnimationFrame(function frame(now) {
const dt = Math.min((now - prev) / 1000, 0.05); prev = now;
velocity *= 0.9; // decay: effect relaxes when scrolling stops
smooth += (velocity - smooth) * 0.08; // lerp: spiky wheel events become liquid motion
x += (BASE + smooth * FACTOR) * dt;
if (half > 0) x = ((x % half) + half) % half; // modulo wrap = no seam at any speed
const skew = Math.max(-12, Math.min(12, smooth * -0.02));
track.style.transform = 'translate3d(' + (-x) + 'px,0,0) skewX(' + skew + 'deg)';
requestAnimationFrame(frame);
});
}
```How this works
Three numbers run the show. A scroll listener records the per-event delta into a raw velocity; the rAF loop (1) eases that raw value toward zero (velocity *= 0.9 — scroll stops, effect relaxes), (2) lerps a smooth copy toward it so spiky wheel events become liquid motion, and (3) advances the marquee offset by (BASE + smooth × FACTOR) × dt. Because smooth is signed, scrolling up doesn't just slow the marquee — it reverses it, which is the moment users notice the page is 'alive'.
The skew is the same signal reused: skewX(clamp(smooth × −0.02, −12, 12)deg) on the track makes the type lean into its motion like italics under acceleration. Offset wraps with the modulo-over-half-width trick from demo 11, so no seam at any speed. The text itself is display-scale outlined type via -webkit-text-stroke with a filled copy alternating — pure fashion, zero extra tech.
Everything is transform-only on one composited track, so even violent scrolling costs one transform update per frame. Under prefers-reduced-motion the listener never attaches and BASE drops to 0 — the strip becomes a static typographic band.
Techniques used: scroll-delta velocity capture with decay + lerp smoothing · signed velocity → speed AND direction · velocity-mapped skewX with clamp · modulo wrap seamless offset · -webkit-text-stroke outline typography · single composited transform per frame · reduced-motion = static band (no listener at all)
Make it yours
- Sensitivity: FACTOR (0.25 here) — how hard page scroll drives the marquee; 0.5+ is chaotic, 0.1 is subliminal.
- Lean: the −0.02 skew multiplier and ±12deg clamp; set 0 to keep acceleration without the italics.
- Idle drift: BASE px/s — 0 makes the marquee move ONLY when the page scrolls (a strong minimal look).
- Smoothing: the 0.08 lerp — lower is dreamier, higher is twitchier.
Gotchas — read before shipping
- Never write transforms inside the scroll event — wheel events fire faster than frames; the listener only records the delta, rAF does all writing.
- Clamp the skew: unclamped fast scrolls shear the text into unreadable parallelograms.
- This demo needs page-scroll room to feel — it ships with tall padding; embedded in a short page it just idles.
- Wrap width must be re-measured on resize/font-load (ResizeObserver), same as any modulo marquee.
- Respect reduced motion by not attaching the listener at all — a scroll-reactive element that merely 'moves less' still violates the user's request.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| all | all | all | all |
rAF, scroll events and text-stroke (prefixed) are universal. The whole effect is progressive enhancement on top of a static type band.