Scroller
Infinite running rows, in pure JavaScript
Marquee
Vanilla JS
Responsive
A tiny, dependency-free library that turns any row of elements into a smooth, seamless infinite marquee . It clones your content automatically, runs on the Web Animations API, and respects prefers-reduced-motion . It works with any markup — cards, logos, text, images. The demos below use plain empty tiles on purpose, so you can see the motion itself.
Live demos
Every example below is the same script with a different config. Hover the interactive ones.
1 · HTML structure
The script needs this exact nesting: an outer container and a .scroller-wrapper holding your items.
<div class="my-scroller">
<div class="scroller-wrapper">
<!-- Your items: cards, logos, text -->
<div class="item">1</div>
<div class="item">2</div>
<div class="item">3</div>
</div>
</div>
2 · CSS (required)
Add these styles once. They set the layout, the edge-fade mask, and the orientation switches the script toggles via data-attributes.
/*! Scroller by leggo.dev v2.2 */
.scroller-wrapper { display: flex; }
[data-animated="true"] { overflow: hidden; }
[data-animated="true"][data-orientation="horizontal"]:not([data-mask-disabled="true"]) {
-webkit-mask: linear-gradient(90deg, transparent, white 20%, white 80%, transparent);
mask: linear-gradient(90deg, transparent, white 20%, white 80%, transparent);
}
[data-animated="true"][data-orientation="horizontal"] .scroller-wrapper {
width: max-content;
flex-wrap: nowrap;
}
[data-animated="true"][data-orientation="vertical"]:not([data-mask-disabled="true"]) {
-webkit-mask: linear-gradient(0deg, transparent, white 20%, white 80%, transparent);
mask: linear-gradient(0deg, transparent, white 20%, white 80%, transparent);
}
[data-animated="true"][data-orientation="vertical"] .scroller-wrapper {
height: max-content;
flex-direction: column;
flex-wrap: nowrap;
}
3 · Settings
Every option is optional — pass only what you need. breakpoints overrides any option per screen width.
| Option | Type | Default | Description |
|---|---|---|---|
| direction | String | 'left' | Travel direction: 'left' , 'right' , 'up' , 'down' . |
| pixelsPerSecond | Number | 50 | Speed of movement — higher is faster. |
| gap | Number | 2 | Space between items, in rem . |
| duplicates | Number | 1 | How many times to clone the set (minimum 1) for a seamless loop. |
| pauseOnHover | Boolean | false | Stop the animation while the cursor is over it. |
| slowOnHover | Boolean | false | Slow down on hover instead of stopping. |
| slowdownFactor | Number | 3 | How many times to slow down (when slowOnHover: true ). |
| removeMask | Boolean | false | Remove the fade (mask) on the edges for hard cut-offs. |
| breakpoints | Object | {} | Per-screen-width overrides for any of the options above. |
4 · Initialization
Call initMyScroller() with a selector and your config. breakpoints let you change speed, gap or duplicates as the viewport grows.
initMyScroller('.my-scroller', {
direction: 'left',
pixelsPerSecond: 60,
gap: 2,
pauseOnHover: true,
breakpoints: {
768: { pixelsPerSecond: 100, gap: 3 }, // tablets and up
1024: { duplicates: 2 } // desktops
}
});
5 · The library (v2.7)
Drop this in once, before your initMyScroller call (or in your global JS). Stability-tested; it re-measures on resize and bails on reduced-motion.
Good to know:
- It clones your items automatically — write each item only once.
-
It waits for any
inside to load before measuring, so the loop never jumps.
- Users with “reduce motion” on simply see a static, scroll-free row.
/* Scroller by leggo.dev v2.7 */
function initMyScroller(s,t={}){function n(e){if(!e.fullConfig)return;const r=e=>{const r={...e},n=window.innerWidth,o=e.breakpoints||{};return Object.keys(o).sort(((e,t)=>e-t)).forEach((e=>{n>=e&&Object.assign(r,o[e])})),r},o=r(e.fullConfig);if(e.scrollerAnimation&&e.currentAppliedConfig===JSON.stringify(o))return;e.currentAppliedConfig=JSON.stringify(o),e.scrollerAnimation?.cancel(),e.eventController?.abort(),e.eventController=new AbortController;const{signal:i}=e.eventController,l=e.querySelector(".scroller-wrapper");if(!l)return;e.originalWrapperHTML?l.innerHTML=e.originalWrapperHTML:e.originalWrapperHTML=l.innerHTML;const a=o.direction||"left",c=o.gap||2,d=o.duplicates||1,p=o.pixelsPerSecond||50,f="up"===a||"down"===a?"vertical":"horizontal";e.setAttribute("data-animated","true"),e.setAttribute("data-orientation",f),o.removeMask&&e.setAttribute("data-mask-disabled","true"),l.style.display="flex","vertical"===f?(l.style.flexDirection="column",l.style.rowGap=c+"rem",l.style.columnGap="0"):(l.style.flexDirection="row",l.style.columnGap=c+"rem",l.style.rowGap="0");const u=l.querySelectorAll("img"),m=Array.from(u).map((e=>e.complete?Promise.resolve():new Promise((t=>{e.onload=t,e.onerror=t}))));Promise.all(m).then((()=>{requestAnimationFrame((()=>{const r=parseFloat(getComputedStyle(document.documentElement).fontSize)*c,n="vertical"===f?l.scrollHeight:l.scrollWidth;if(n{const t=e.cloneNode(!0);t.setAttribute("aria-hidden","true"),l.appendChild(t)}))}const u=n+r,m=(u/p)*1000,w="vertical"===f?"translateY":"translateX",v=l.animate([{transform:`${w}(0)`},{transform:`${w}(-${u}px)`}],{duration:m,iterations:1/0,easing:"linear",direction:"right"===a||"down"===a?"reverse":"normal"});e.scrollerAnimation=v,o.pauseOnHover?(e.addEventListener("mouseenter",(()=>v.pause()),{signal:i}),e.addEventListener("mouseleave",(()=>v.play()),{signal:i})):o.slowOnHover&&(e.addEventListener("mouseenter",(()=>v.playbackRate=1/(o.slowdownFactor||3)),{signal:i}),e.addEventListener("mouseleave",(()=>v.playbackRate=1),{signal:i}))}))}))}const o=document.querySelectorAll(s);if(!window.matchMedia("(prefers-reduced-motion: reduce)").matches){let i=window.innerWidth;o.forEach((e=>{e.fullConfig=t,n(e),window.addEventListener("resize",(()=>{window.innerWidth!==i&&(i=window.innerWidth,n(e))}))}))}}