Infinite Scroller

A seamless marquee for logos, tags and cards in under 3 KB. It fills the row by itself, pauses when someone needs it to, and stays quiet for screen readers.

  • Vanilla JS
  • No dependencies
  • Web Animations API
  • Accessible

See it move

Every row is the same script with different data- attributes. Hover them, Tab into the links, or resize the window.

Logo row fills any width, slows on hover

data-scroller-hover="slow"
  • Webflow
  • WordPress
  • Builderius
  • GSAP
  • Bricks
  • n8n
  • Figma
  • Static HTML

Reversed travels to the right

data-scroller-direction="right"
  • #performance
  • #accessibility
  • #core-web-vitals
  • #vanilla-js
  • #no-dependencies
  • #seo
  • #figma-to-web

Pause on hover, links inside hover or Tab to a link — it stops

data-scroller-hover="pause"

Edge fade default

mask on
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

Hard edges no mask

data-scroller-mask="false"
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

Vertical · up give it a height

direction="up"
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

Vertical · down same script

direction="down"
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

A marquee that behaves.

Version 3 is a rewrite. Everything below is tested in a real browser, on the readable and the minified build.

  • Fills the row by itself

    It works out how many copies cover the box, so a short list never leaves an empty gap on a wide screen. No duplicate count to guess.

  • Measures, never guesses

    The loop length comes from the real layout — gaps and margins included — and it re-measures when images, fonts or the layout change, keeping its place.

  • A pause for everyone

    Stop or slow down on hover, stop while a link inside has keyboard focus, and add a pause button in one attribute — what WCAG 2.2.2 asks for.

  • Clean for screen readers

    Items are read once. The copies are hidden, inert and stripped of ids, so there are no repeated announcements and no extra Tab stops.

  • Light on the CPU

    One Web Animations API animation per row, running on the compositor, and it simply stops while the row is off screen.

  • Motion is optional

    With reduced motion turned on, the row becomes a plain wrapped list — and switches live if the setting changes. v2's initMyScroller() still works.

Add it to your site.

Markup, a little CSS and the script. Configure each row with attributes — no JavaScript to write.

  1. Markup

    data-scroller on the box, data-scroller-track on the list. Write each item once. The pause button is optional and can live anywhere.

    HTML
    <!-- Optional: a pause button, anywhere on the page -->
    <button class="scroller-toggle" data-scroller-toggle="clients">Pause</button>
    
    <div class="scroller" id="clients" data-scroller
         data-scroller-direction="left" data-scroller-speed="50" data-scroller-hover="slow"
         aria-label="Clients">
      <ul class="scroller-track" data-scroller-track>
        <li>Item 1</li>
        <li>Item 2</li>
        <li>Item 3</li>
        <li>Item 4</li>
      </ul>
    </div>

  2. CSS

    Layout, the edge fade and the static fallback. Set the gap here, and give vertical scrollers a height.

    CSS
    /*! LeggoScroller v3.0 — base styles | leggo.dev/tools/scroller | MIT
       ─────────────────────────────────────────────────────────────────
       What to change where:
         gap between items ............. .scroller-track → gap (or the gap option)
         edge fade width ............... mask gradients below (12% / 88%)
         vertical height ............... give the scroller a height, e.g. 20rem
         pause button .................. .scroller-toggle
       The script sets data-scroller-state on the root: running | paused | static.
       No z-index is used.
       ───────────────────────────────────────────────────────────────── */
    
    .scroller-track,
    .scroller-wrapper {
        display: flex;
        gap: 2rem;
        width: max-content;
        margin: 0;
        padding: 0;
        list-style: none;
    }
    
    .scroller-track > *,
    .scroller-wrapper > * {
        flex: none;
    }
    
    [data-scroller-state="running"],
    [data-scroller-state="paused"] {
        overflow: hidden;
    }
    
    [data-scroller-state="running"] :is(.scroller-track, .scroller-wrapper) {
        will-change: transform;
    }
    
    /* Vertical: the scroller needs a height of its own */
    [data-scroller-orientation="vertical"] :is(.scroller-track, .scroller-wrapper) {
        flex-direction: column;
        width: auto;
    }
    
    /* Edge fade */
    [data-scroller-mask="true"][data-scroller-orientation="horizontal"] {
        -webkit-mask: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
        mask: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
    }
    
    [data-scroller-mask="true"][data-scroller-orientation="vertical"] {
        -webkit-mask: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent);
        mask: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent);
    }
    
    /* Reduced motion or nothing to loop: a plain row that wraps */
    [data-scroller-state="static"] :is(.scroller-track, .scroller-wrapper) {
        flex-wrap: wrap;
        width: auto;
    }
    
    [data-scroller-state="static"][data-scroller-orientation="vertical"] {
        height: auto;
    }
    
    /* Optional pause button */
    .scroller-toggle {
        display: inline-flex;
        align-items: center;
        gap: 0.5rem;
        padding: 0.5rem 0.875rem;
        border: 1px solid currentColor;
        border-radius: 100px;
        background: transparent;
        color: inherit;
        font: inherit;
        font-size: 0.875rem;
        cursor: pointer;
    }
    
    .scroller-toggle:focus-visible {
        outline: 2px solid #2B5CFF;
        outline-offset: 2px;
    }
    
    .scroller-toggle[aria-pressed="true"] {
        background-color: #0D1109;
        color: #FFFFFF;
    }

  3. JavaScript

    Load it once, with defer. It starts every data-scroller on the page by itself.

    JavaScript
    /*! LeggoScroller v3.0 | leggo.dev/tools/scroller | MIT */
    ((t,e)=>{"use strict";const s={direction:"left",speed:50,gap:null,hover:"none",slowdown:3,mask:!0,duplicates:0,breakpoints:{}},i=new WeakMap,r=t.matchMedia("(prefers-reduced-motion: reduce)");class o{#t;#e;#s;#i;#r=[];#o=[];#n=[];#a=null;#l=new Set;#c="";#h=0;#d=0;#u=null;#p=null;#g=new Map;#m={};constructor(t,r={}){if(!t)throw new Error("LeggoScroller: root element not passed.");const o=i.get(t);if(o)return o;if(this.#t=t,this.#e=t.querySelector("[data-scroller-track], .scroller-wrapper"),!this.#e)throw new Error("LeggoScroller: track missing.");this.#s={...s,...this.#f(t),...r},this.#r=Array.from(this.#e.children),this.#n=Array.from(t.querySelectorAll("[data-scroller-toggle]")).filter(t=>!this.#e.contains(t)),t.id&&e.querySelectorAll(`[data-scroller-toggle="${CSS.escape(t.id)}"]`).forEach(t=>{this.#n.includes(t)||this.#n.push(t)}),this.#v(),i.set(t,this),this.#b()}get paused(){return this.#l.has("user")}get running(){return!!this.#a&&"running"===this.#a.playState}play(){this.#l.delete("user"),this.#y(),this.#k()}pause(){this.#l.add("user"),this.#y(),this.#k()}toggle(){this.paused?this.play():this.pause()}refresh(){this.#w(),this.#r=Array.from(this.#e.children),this.#A(),this.#c="",this.#b()}destroy(){const t=this.#m;cancelAnimationFrame(this.#h),cancelAnimationFrame(this.#d),this.#u?.disconnect(),this.#p?.disconnect(),r.removeEventListener?.("change",t.motion),this.#t.removeEventListener("pointerenter",t.enter),this.#t.removeEventListener("pointerleave",t.leave),this.#t.removeEventListener("focusin",t.focusin),this.#t.removeEventListener("focusout",t.focusout),this.#n.forEach(e=>e.removeEventListener("click",t.toggle)),this.#a?.cancel(),this.#a=null,this.#w(),this.#g.forEach((t,e)=>{Object.entries(t).forEach(([t,s])=>{null===s?e.removeAttribute(t):e.setAttribute(t,s)})}),this.#g.clear(),i.delete(this.#t)}static get(t){return i.get(t)||null}static init(t,s=e){return Array.from(s.querySelectorAll("[data-scroller]"),e=>{try{return new o(e,t)}catch(t){return null}}).filter(Boolean)}#f(t){const e=t.dataset,s={},i=(t,e,s)=>{const i=parseFloat(t);return Number.isNaN(i)?void 0:Math.min(s,Math.max(e,i))};/^(left|right|up|down)$/.test(e.scrollerDirection||"")&&(s.direction=e.scrollerDirection),/^(none|pause|slow)$/.test(e.scrollerHover||"")&&(s.hover=e.scrollerHover);const r=i(e.scrollerSpeed,1,2e3),o=i(e.scrollerSlowdown,1,20),n=i(e.scrollerDuplicates,0,20);return void 0!==r&&(s.speed=r),void 0!==o&&(s.slowdown=o),void 0!==n&&(s.duplicates=Math.round(n)),e.scrollerGap&&(s.gap=/^[\d.]+$/.test(e.scrollerGap)?parseFloat(e.scrollerGap):e.scrollerGap),void 0!==e.scrollerMask&&(s.mask="false"!==e.scrollerMask),s}#E(t,e,s){let i=this.#g.get(t);i||(i={},this.#g.set(t,i)),e in i||(i[e]=t.getAttribute(e)),null!==s?t.getAttribute(e)!==s&&t.setAttribute(e,s):t.removeAttribute(e)}#v(){const e=this.#m;e.enter=t=>{"mouse"===t.pointerType&&this.#S(!0)},e.leave=t=>{"mouse"===t.pointerType&&this.#S(!1)},e.focusin=()=>{this.#l.add("focus"),this.#y()},e.focusout=t=>{this.#t.contains(t.relatedTarget)||(this.#l.delete("focus"),this.#y())},e.toggle=()=>this.toggle(),e.motion=()=>{this.#c="",this.#b()},this.#t.addEventListener("pointerenter",e.enter),this.#t.addEventListener("pointerleave",e.leave),this.#t.addEventListener("focusin",e.focusin),this.#t.addEventListener("focusout",e.focusout),this.#n.forEach(t=>{"BUTTON"!==t.tagName||t.hasAttribute("type")||this.#E(t,"type","button"),this.#e.id||this.#E(this.#e,"id",`leggo-scroller-${Math.random().toString(36).slice(2,8)}`),this.#E(t,"aria-controls",this.#e.id),t.addEventListener("click",e.toggle)}),r.addEventListener?.("change",e.motion),"ResizeObserver"in t&&(this.#u=new ResizeObserver(()=>{this.#h||(this.#h=requestAnimationFrame(()=>{this.#h=0,this.#b()}))}),this.#u.observe(this.#t),this.#A()),"IntersectionObserver"in t&&(this.#p=new IntersectionObserver(t=>{t[0].isIntersecting?this.#l.delete("offscreen"):this.#l.add("offscreen"),this.#y()},{rootMargin:"100px"}),this.#p.observe(this.#t))}#A(){this.#r.forEach(t=>this.#u?.observe(t))}#M(){const e={...this.#s},s=this.#s.breakpoints||{};return Object.keys(s).map(Number).filter(t=>!Number.isNaN(t)).sort((t,e)=>t-e).forEach(i=>{t.innerWidth>=i&&Object.assign(e,s[i])}),e.vertical="up"===e.direction||"down"===e.direction,e}#b(){const t=this.#M(),e=t.vertical,s=this.#t,i=r.matches;if(this.#E(s,"data-scroller-orientation",e?"vertical":"horizontal"),this.#E(s,"data-scroller-mask",String(!!t.mask&&!i)),null!==t.gap&&void 0!==t.gap){const e="number"==typeof t.gap?`${t.gap}rem`:String(t.gap);this.#e.style.gap!==e&&(this.#E(this.#e,"style",this.#e.getAttribute("style")||""),this.#e.style.gap=e)}const o=this.#r[0],n=this.#r[this.#r.length-1],a=o?e?n.getBoundingClientRect().bottom-o.getBoundingClientRect().top:n.getBoundingClientRect().right-o.getBoundingClientRect().left:0,l=e?s.clientHeight:s.clientWidth,c=[JSON.stringify(t),Math.round(a),Math.round(l),i].join("|");if(c===this.#c)return;this.#c=c;const h=this.#a,d=h?.effect?.getTiming().duration,u=h&&d?(h.currentTime??0)%d/d:0;if(this.#a?.cancel(),this.#a=null,this.#w(),this.#i=t,i||!o||a<=0)return this.#E(s,"data-scroller-state","static"),void this.#C();this.#L();const p=o.getBoundingClientRect(),g=this.#o[0].getBoundingClientRect(),m=e?g.top-p.top:g.left-p.left;if(m<=0)return this.#w(),void this.#E(s,"data-scroller-state","static");const f=Math.max(2,Math.ceil(l/m)+1)+Math.max(0,0|t.duplicates);for(let t=2;t<f;t++)this.#L();const v=e?"translateY":"translateX",b=m/Math.max(1,t.speed)*1e3;this.#a=this.#e.animate([{transform:`${v}(0)`},{transform:`${v}(${-m}px)`}],{duration:b,iterations:1/0,easing:"linear",direction:"right"===t.direction||"down"===t.direction?"reverse":"normal"}),this.#a.currentTime=u*b,this.#E(s,"data-scroller-state","running"),this.#y()}#L(){const t=e.createDocumentFragment();this.#r.forEach(e=>{const s=e.cloneNode(!0);s.setAttribute("aria-hidden","true"),s.setAttribute("inert",""),s.setAttribute("data-scroller-clone",""),s.removeAttribute("id"),s.querySelectorAll("[id]").forEach(t=>t.removeAttribute("id")),t.appendChild(s),this.#o.push(s)}),this.#e.appendChild(t)}#w(){this.#o.forEach(t=>t.remove()),this.#o=[]}#S(t){const e=this.#i?.hover;"pause"===e?(t?this.#l.add("hover"):this.#l.delete("hover"),this.#y()):"slow"===e&&this.#R(t?1/Math.max(1,this.#i.slowdown):1)}#R(t){const e=this.#a;if(!e)return;cancelAnimationFrame(this.#d);const s=e.playbackRate,i=performance.now(),r=o=>{const n=Math.min(1,(o-i)/280);e.playbackRate=s+(t-s)*(1-(1-n)**3),n<1&&(this.#d=requestAnimationFrame(r))};this.#d=requestAnimationFrame(r)}#y(){const t=this.#a;if(t){const e=this.#l.size>0;e&&"running"===t.playState&&t.pause(),e||"paused"!==t.playState||t.play(),this.#E(this.#t,"data-scroller-state",e?"paused":"running")}this.#C()}#C(){const t=String(this.paused),e="static"===this.#t.getAttribute("data-scroller-state");this.#n.forEach(s=>{this.#E(s,"aria-pressed",t),this.#E(s,"hidden",e?"":null)})}#k(){this.#t.dispatchEvent(new CustomEvent("scroller:toggle",{bubbles:!0,detail:{scroller:this,paused:this.paused}}))}}const n=(t={})=>{const e={...t};return void 0!==t.pixelsPerSecond&&(e.speed=t.pixelsPerSecond),t.pauseOnHover?e.hover="pause":t.slowOnHover&&(e.hover="slow"),void 0!==t.slowdownFactor&&(e.slowdown=t.slowdownFactor),t.removeMask&&(e.mask=!1),void 0!==t.duplicates&&(e.duplicates=Math.max(0,t.duplicates-1)),void 0===t.gap&&(e.gap=2),t.breakpoints&&(e.breakpoints={},Object.entries(t.breakpoints).forEach(([t,s])=>{e.breakpoints[t]=n({gap:s.gap??e.gap,...s})})),e};t.initMyScroller=(t,s)=>Array.from(e.querySelectorAll(t),t=>new o(t,n(s))),"loading"===e.readyState?e.addEventListener("DOMContentLoaded",()=>o.init(),{once:!0}):o.init(),t.LeggoScroller=o})(window,document);

Options and API.

Every option works as a data-scroller-* attribute or as a JavaScript option. All of them are optional.

Option Default What it does
direction left left, right, up or down.
speed 50 Pixels per second, the same on every screen size.
hover none pause stops the row under the mouse, slow eases it down.
slowdown 3 How many times slower with hover: slow.
gap from CSS A number in rem or any CSS length. Leave it out to use the gap from your stylesheet.
mask true false for hard edges instead of the fade.
duplicates 0 Extra copies on top of the ones needed to fill the row.
breakpoints {} JS only: { 768: { speed: 80 } } overrides options from that width up.
data-scroller-toggle — On a button: pauses and resumes. Inside the scroller, or anywhere with the scroller's id as the value.
data-scroller-state — Set by the script: running, paused or static.
LeggoScroller.get(el) JS The instance: .play(), .pause(), .toggle(), .refresh(), .destroy().
scroller:toggle event Fires when the pause button or the API pauses or resumes; detail.paused.

Questions about the scroller.

Does it need jQuery or any library?

No. It's plain JavaScript on the Web Animations API — under 3 KB gzipped. Load it with defer and every data-scroller on the page starts by itself.

How many copies of my items do I need?

One. Write each item once; the script measures the row and adds as many hidden copies as it takes to cover the box on any screen, then rebuilds when the size changes.

Can it scroll logos and images?

Yes — anything goes inside: images, SVG logos, cards, links, text. It re-measures when images finish loading, so the loop never jumps.

Is a moving row accessible?

It can be. The copies are hidden from screen readers and the keyboard, keyboard focus inside the row pauses it, and one data-scroller-toggle button gives everyone a pause — WCAG 2.2.2 asks for exactly that. With reduced motion on, it doesn't move at all.

How do I make a vertical scroller?

Set data-scroller-direction to up or down and give the scroller a height in CSS. Everything else is the same.

I use v2 with initMyScroller(). Do I have to change anything?

No. initMyScroller(selector, options) and the .scroller-wrapper class still work, including pixelsPerSecond, pauseOnHover, slowOnHover, removeMask and breakpoints. Swap the script and CSS.

Does it work in Webflow, WordPress and Builderius?

Yes. It only needs the markup, the CSS and the script. In Builderius, paste the markup through the HTML → Builderius converter and add the script as a JS snippet.

Can I use it in client projects?

Yes. It's free and MIT-licensed — keep the one-line comment at the top of the file.

Want a site where every animation is this careful?

Get a quote