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.
See it move
Every row is the same script with different data- attributes. Hover them, Tab into the links, or resize the window.
- Webflow
- WordPress
- Builderius
- GSAP
- Bricks
- n8n
- Figma
- Static HTML
- #performance
- #accessibility
- #core-web-vitals
- #vanilla-js
- #no-dependencies
- #seo
- #figma-to-web
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 1
- 2
- 3
- 4
- 5
- 6
- 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.
-
Markup
data-scrolleron the box,data-scroller-trackon 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> -
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; } -
JavaScript
Load it once, with
defer. It starts everydata-scrolleron 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);/*! LeggoScroller v3.0 — infinite marquee, vanilla JS | leggo.dev/tools/scroller | MIT */ ((window, document) => { 'use strict'; const DEFAULTS = { direction: 'left', // 'left' | 'right' | 'up' | 'down' speed: 50, // px per second gap: null, // null = take it from CSS; number = rem; string = any CSS length hover: 'none', // 'none' | 'pause' | 'slow' slowdown: 3, // how many times slower with hover: 'slow' mask: true, // fade the edges duplicates: 0, // extra copies on top of what's needed to fill the row breakpoints: {} // { 768: { speed: 80 }, ... } — min-width overrides }; // data-* attributes are the main hooks; .scroller-wrapper keeps v2 markup working. const SEL = { root: '[data-scroller]', track: '[data-scroller-track], .scroller-wrapper', toggle: '[data-scroller-toggle]' }; const instances = new WeakMap(); const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); const RATE_EASE = 280; // ms to glide between speeds with hover: 'slow' class LeggoScroller { #root; #track; #opt; #cfg; #items = []; #clones = []; #toggles = []; #anim = null; #reasons = new Set(); // why we're paused: user, hover, focus, offscreen #signature = ''; #raf = 0; #rateRaf = 0; #ro = null; #io = null; #touched = new Map(); // element -> { attribute: original value | null } #h = {}; constructor(root, options = {}) { if (!root) throw new Error('LeggoScroller: root element not passed.'); const existing = instances.get(root); if (existing) return existing; this.#root = root; this.#track = root.querySelector(SEL.track); if (!this.#track) throw new Error('LeggoScroller: track missing.'); this.#opt = { ...DEFAULTS, ...this.#readDataset(root), ...options }; this.#items = Array.from(this.#track.children); this.#toggles = Array.from(root.querySelectorAll(SEL.toggle)).filter((b) => !this.#track.contains(b)); if (root.id) { document.querySelectorAll(`[data-scroller-toggle="${CSS.escape(root.id)}"]`).forEach((b) => { if (!this.#toggles.includes(b)) this.#toggles.push(b); }); } this.#bind(); instances.set(root, this); this.#build(); } /* ── Public API ────────────────────────────────────────── */ get paused() { return this.#reasons.has('user'); } get running() { return !!this.#anim && this.#anim.playState === 'running'; } play() { this.#reasons.delete('user'); this.#apply(); this.#emit(); } pause() { this.#reasons.add('user'); this.#apply(); this.#emit(); } toggle() { this.paused ? this.play() : this.pause(); } /** Rebuild after changing the items from code. */ refresh() { this.#removeClones(); this.#items = Array.from(this.#track.children); this.#observeItems(); this.#signature = ''; this.#build(); } destroy() { const h = this.#h; cancelAnimationFrame(this.#raf); cancelAnimationFrame(this.#rateRaf); this.#ro?.disconnect(); this.#io?.disconnect(); reducedMotion.removeEventListener?.('change', h.motion); this.#root.removeEventListener('pointerenter', h.enter); this.#root.removeEventListener('pointerleave', h.leave); this.#root.removeEventListener('focusin', h.focusin); this.#root.removeEventListener('focusout', h.focusout); this.#toggles.forEach((b) => b.removeEventListener('click', h.toggle)); this.#anim?.cancel(); this.#anim = null; this.#removeClones(); this.#touched.forEach((attrs, el) => { Object.entries(attrs).forEach(([name, value]) => { value === null ? el.removeAttribute(name) : el.setAttribute(name, value); }); }); this.#touched.clear(); instances.delete(this.#root); } static get(root) { return instances.get(root) || null; } static init(options, scope = document) { return Array.from(scope.querySelectorAll(SEL.root), (root) => { try { return new LeggoScroller(root, options); } catch (e) { return null; } }).filter(Boolean); } /* ── Setup ─────────────────────────────────────────────── */ #readDataset(root) { const d = root.dataset; const out = {}; const num = (raw, min, max) => { const n = parseFloat(raw); return Number.isNaN(n) ? undefined : Math.min(max, Math.max(min, n)); }; if (/^(left|right|up|down)$/.test(d.scrollerDirection || '')) out.direction = d.scrollerDirection; if (/^(none|pause|slow)$/.test(d.scrollerHover || '')) out.hover = d.scrollerHover; const speed = num(d.scrollerSpeed, 1, 2000); const slowdown = num(d.scrollerSlowdown, 1, 20); const dup = num(d.scrollerDuplicates, 0, 20); if (speed !== undefined) out.speed = speed; if (slowdown !== undefined) out.slowdown = slowdown; if (dup !== undefined) out.duplicates = Math.round(dup); if (d.scrollerGap) out.gap = /^[\d.]+$/.test(d.scrollerGap) ? parseFloat(d.scrollerGap) : d.scrollerGap; if (d.scrollerMask !== undefined) out.mask = d.scrollerMask !== 'false'; return out; } // Remember the original value before the first write, so destroy() can restore it. #set(el, name, value) { let saved = this.#touched.get(el); if (!saved) { saved = {}; this.#touched.set(el, saved); } if (!(name in saved)) saved[name] = el.getAttribute(name); if (value === null) { el.removeAttribute(name); return; } if (el.getAttribute(name) !== value) el.setAttribute(name, value); } #bind() { const h = this.#h; // Mouse only: on touch a tap would freeze the row until the next tap somewhere else. h.enter = (e) => { if (e.pointerType === 'mouse') this.#hover(true); }; h.leave = (e) => { if (e.pointerType === 'mouse') this.#hover(false); }; // A keyboard user must be able to reach a link before it scrolls away (WCAG 2.2.2). h.focusin = () => { this.#reasons.add('focus'); this.#apply(); }; h.focusout = (e) => { if (this.#root.contains(e.relatedTarget)) return; this.#reasons.delete('focus'); this.#apply(); }; h.toggle = () => this.toggle(); h.motion = () => { this.#signature = ''; this.#build(); }; this.#root.addEventListener('pointerenter', h.enter); this.#root.addEventListener('pointerleave', h.leave); this.#root.addEventListener('focusin', h.focusin); this.#root.addEventListener('focusout', h.focusout); this.#toggles.forEach((b) => { if (b.tagName === 'BUTTON' && !b.hasAttribute('type')) this.#set(b, 'type', 'button'); if (!this.#track.id) this.#set(this.#track, 'id', `leggo-scroller-${Math.random().toString(36).slice(2, 8)}`); this.#set(b, 'aria-controls', this.#track.id); b.addEventListener('click', h.toggle); }); reducedMotion.addEventListener?.('change', h.motion); // Sizes change for more reasons than a window resize: images, fonts, new content. if ('ResizeObserver' in window) { this.#ro = new ResizeObserver(() => { if (this.#raf) return; this.#raf = requestAnimationFrame(() => { this.#raf = 0; this.#build(); }); }); this.#ro.observe(this.#root); this.#observeItems(); } // Nobody sees it off screen — don't spend frames on it. if ('IntersectionObserver' in window) { this.#io = new IntersectionObserver((entries) => { entries[0].isIntersecting ? this.#reasons.delete('offscreen') : this.#reasons.add('offscreen'); this.#apply(); }, { rootMargin: '100px' }); this.#io.observe(this.#root); } } #observeItems() { this.#items.forEach((el) => this.#ro?.observe(el)); } /* ── Build ─────────────────────────────────────────────── */ #config() { const cfg = { ...this.#opt }; const bp = this.#opt.breakpoints || {}; Object.keys(bp).map(Number).filter((w) => !Number.isNaN(w)).sort((a, b) => a - b) .forEach((w) => { if (window.innerWidth >= w) Object.assign(cfg, bp[w]); }); cfg.vertical = cfg.direction === 'up' || cfg.direction === 'down'; return cfg; } #build() { const cfg = this.#config(); const vertical = cfg.vertical; const root = this.#root; const still = reducedMotion.matches; this.#set(root, 'data-scroller-orientation', vertical ? 'vertical' : 'horizontal'); this.#set(root, 'data-scroller-mask', String(!!cfg.mask && !still)); if (cfg.gap !== null && cfg.gap !== undefined) { const gap = typeof cfg.gap === 'number' ? `${cfg.gap}rem` : String(cfg.gap); if (this.#track.style.gap !== gap) { this.#set(this.#track, 'style', this.#track.getAttribute('style') || ''); this.#track.style.gap = gap; } } // Measure the original set: from the start of the first item to the end of the last one. const first = this.#items[0]; const last = this.#items[this.#items.length - 1]; const size = first ? (vertical ? last.getBoundingClientRect().bottom - first.getBoundingClientRect().top : last.getBoundingClientRect().right - first.getBoundingClientRect().left) : 0; const box = vertical ? root.clientHeight : root.clientWidth; const signature = [JSON.stringify(cfg), Math.round(size), Math.round(box), still].join('|'); if (signature === this.#signature) return; this.#signature = signature; // Keep the position through a rebuild, so a resize doesn't restart the row. const old = this.#anim; const oldDur = old?.effect?.getTiming().duration; const progress = old && oldDur ? ((old.currentTime ?? 0) % oldDur) / oldDur : 0; this.#anim?.cancel(); this.#anim = null; this.#removeClones(); this.#cfg = cfg; if (still || !first || size <= 0) { this.#set(root, 'data-scroller-state', 'static'); this.#syncToggles(); return; } // One full copy tells us the exact period — gap and margins included. this.#appendCopy(); const a = first.getBoundingClientRect(); const b = this.#clones[0].getBoundingClientRect(); const period = vertical ? b.top - a.top : b.left - a.left; if (period <= 0) { this.#removeClones(); this.#set(root, 'data-scroller-state', 'static'); return; } // Enough copies to cover the box plus one period, so the edge never shows a gap. const sets = Math.max(2, Math.ceil(box / period) + 1) + Math.max(0, cfg.duplicates | 0); for (let i = 2; i < sets; i++) this.#appendCopy(); const axis = vertical ? 'translateY' : 'translateX'; const duration = (period / Math.max(1, cfg.speed)) * 1000; this.#anim = this.#track.animate( [{ transform: `${axis}(0)` }, { transform: `${axis}(${-period}px)` }], { duration, iterations: Infinity, easing: 'linear', direction: cfg.direction === 'right' || cfg.direction === 'down' ? 'reverse' : 'normal' } ); this.#anim.currentTime = progress * duration; this.#set(root, 'data-scroller-state', 'running'); this.#apply(); } #appendCopy() { const frag = document.createDocumentFragment(); this.#items.forEach((item) => { const clone = item.cloneNode(true); // Copies are decoration: hidden from screen readers, out of the tab order, no duplicate ids. clone.setAttribute('aria-hidden', 'true'); clone.setAttribute('inert', ''); clone.setAttribute('data-scroller-clone', ''); clone.removeAttribute('id'); clone.querySelectorAll('[id]').forEach((n) => n.removeAttribute('id')); frag.appendChild(clone); this.#clones.push(clone); }); this.#track.appendChild(frag); } #removeClones() { this.#clones.forEach((c) => c.remove()); this.#clones = []; } /* ── Playback ──────────────────────────────────────────── */ #hover(on) { const mode = this.#cfg?.hover; if (mode === 'pause') { on ? this.#reasons.add('hover') : this.#reasons.delete('hover'); this.#apply(); } else if (mode === 'slow') { this.#glideTo(on ? 1 / Math.max(1, this.#cfg.slowdown) : 1); } } // Ease the speed instead of snapping it — a sudden jump reads as a glitch. #glideTo(target) { const anim = this.#anim; if (!anim) return; cancelAnimationFrame(this.#rateRaf); const from = anim.playbackRate; const t0 = performance.now(); const step = (now) => { const k = Math.min(1, (now - t0) / RATE_EASE); anim.playbackRate = from + (target - from) * (1 - (1 - k) ** 3); if (k < 1) this.#rateRaf = requestAnimationFrame(step); }; this.#rateRaf = requestAnimationFrame(step); } #apply() { const anim = this.#anim; if (anim) { const stop = this.#reasons.size > 0; if (stop && anim.playState === 'running') anim.pause(); if (!stop && anim.playState === 'paused') anim.play(); this.#set(this.#root, 'data-scroller-state', stop ? 'paused' : 'running'); } this.#syncToggles(); } #syncToggles() { const pressed = String(this.paused); const hide = this.#root.getAttribute('data-scroller-state') === 'static'; this.#toggles.forEach((b) => { this.#set(b, 'aria-pressed', pressed); this.#set(b, 'hidden', hide ? '' : null); // nothing moves — nothing to pause }); } #emit() { this.#root.dispatchEvent(new CustomEvent('scroller:toggle', { bubbles: true, detail: { scroller: this, paused: this.paused } })); } } // v2 API: initMyScroller('.my-scroller', { pixelsPerSecond, pauseOnHover, ... }) keeps working. const fromV2 = (c = {}) => { const out = { ...c }; if (c.pixelsPerSecond !== undefined) out.speed = c.pixelsPerSecond; if (c.pauseOnHover) out.hover = 'pause'; else if (c.slowOnHover) out.hover = 'slow'; if (c.slowdownFactor !== undefined) out.slowdown = c.slowdownFactor; if (c.removeMask) out.mask = false; if (c.duplicates !== undefined) out.duplicates = Math.max(0, c.duplicates - 1); if (c.gap === undefined) out.gap = 2; if (c.breakpoints) { out.breakpoints = {}; Object.entries(c.breakpoints).forEach(([w, v]) => { out.breakpoints[w] = fromV2({ gap: v.gap ?? out.gap, ...v }); }); } return out; }; window.initMyScroller = (selector, config) => Array.from(document.querySelectorAll(selector), (el) => new LeggoScroller(el, fromV2(config))); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => LeggoScroller.init(), { once: true }); } else { LeggoScroller.init(); } window.LeggoScroller = LeggoScroller; })(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.