Scroll-linked Marquee
A band of text drifts along by itself and answers the scroll: the faster you scroll, the faster it runs, and it turns round to run the other way when you scroll back up. Every change eases in, so it never jerks.
Scroll inside ↓
Design✦Build✦Ship✦Repeat✦
Design✦Build✦Ship✦Repeat✦
Design✦Build✦Ship✦Repeat✦
How to use it
- Paste the snippet just before
</head>. - Add
data-scroll-linked-marqueeto a row of text or logos. It repeats its contents to fill the width.
Options
Add any of these attributes to the same element.
data-speed | How fast it drifts when you’re not scrolling, in px per second. Default 50. |
data-boost | How much scrolling speeds it up, as a share of the scroll speed. Default 0.5. |
data-reverse | Run the other way: alternate it on stacked rows. |
data-gap | Space between repeats, in px. Default 32. |
With reduced motion the text stands still. Works inside scrolling boxes too.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Scroll-linked Marquee: a band of text that drifts along by itself and
// reacts to the scroll: it speeds up the faster you scroll, and turns
// round to run the other way when you scroll back up, easing through
// each change so it never jerks.
// Usage: <div data-scroll-linked-marquee>Design ✦ Build ✦ Ship ✦</div>
// Its contents repeat to fill the width, so any inline content works.
// Optional: data-speed (drift when you're not scrolling, px per second,
// default 50), data-boost (how much scrolling speeds it up, default 0.5:
// half the scroll speed), data-reverse (run the other way, for
// alternating rows), data-gap (space between repeats, px, default 32).
// Works inside scrolling boxes too: it follows the nearest scrolling parent.
var DEFAULTS = { speed: 50, boost: 0.5, gap: 32 };
var css =
'.slmq{overflow:hidden;white-space:nowrap}' +
'.slmq-track{display:flex;width:max-content;will-change:transform}' +
'.slmq-group{display:inline-flex;align-items:center;flex:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }
function scroller(el) {
for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
var o = getComputedStyle(p).overflowY;
if (o === 'auto' || o === 'scroll') return p;
}
return null;
}
function setup(el) {
var d = el.dataset;
var base = num(d.speed, DEFAULTS.speed);
var boost = num(d.boost, DEFAULTS.boost);
var gap = num(d.gap, DEFAULTS.gap);
var side = d.reverse !== undefined ? -1 : 1;
var box = scroller(el);
el.classList.add('slmq');
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', el.textContent.replace(/\s+/g, ' ').trim());
var group = document.createElement('span');
group.className = 'slmq-group';
group.style.paddingRight = gap + 'px';
group.setAttribute('aria-hidden', 'true');
while (el.firstChild) group.appendChild(el.firstChild);
var track = document.createElement('div');
track.className = 'slmq-track';
track.appendChild(group);
el.appendChild(track);
var groupW = 0;
function fill() {
groupW = group.offsetWidth;
if (!groupW) return;
var need = Math.ceil(el.clientWidth / groupW) + 1;
while (track.children.length < need + 1) track.appendChild(group.cloneNode(true));
}
function getY() { return box ? box.scrollTop : window.pageYOffset; }
var x = 0, dir = 1, flow = 1, vel = 0, speed = base;
var lastY = getY(), lastT = 0, running = false, visible = false;
function frame(now) {
if (!visible) { running = false; return; }
var dt = lastT ? Math.min(0.05, (now - lastT) / 1000) : 0;
lastT = now;
var y = getY(), dy = y - lastY;
lastY = y;
if (dt) {
var v = dy / dt; // scroll speed, px per second
if (Math.abs(dy) > 0.5) dir = dy > 0 ? 1 : -1;
vel += (v - vel) * 0.18;
flow += (dir - flow) * 0.05; // turns round gently
var want = flow * base + vel * boost;
speed += (want - speed) * 0.12;
x -= side * speed * dt;
if (groupW) x = ((x % groupW) - groupW) % groupW;
track.style.transform = 'translate3d(' + x.toFixed(2) + 'px,0,0)';
}
requestAnimationFrame(frame);
}
fill();
if (window.ResizeObserver) new ResizeObserver(fill).observe(el);
else window.addEventListener('resize', fill);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(fill);
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible && !running) {
running = true;
lastT = 0;
lastY = getY();
requestAnimationFrame(frame);
}
}).observe(el);
}
function init() {
document.querySelectorAll('[data-scroll-linked-marquee]').forEach(function (el) {
if (el._slmq) return;
el._slmq = true;
if (reduced()) { el.style.overflow = 'hidden'; el.style.whiteSpace = 'nowrap'; return; }
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-scroll-linked-marquee>Design ✦ Build ✦ Ship ✦ Repeat ✦</div>
<div data-scroll-linked-marquee data-reverse>Design ✦ Build ✦ Ship ✦ Repeat ✦</div>