Vanilla JS Slider
A drag-and-snap carousel in about 4 KB. Cards size to their own content, the track snaps to each one, and it works with a mouse, a finger, a trackpad and a keyboard.
Try it
Drag with the mouse, swipe, scroll sideways on a trackpad, or focus a slider and use ← →.
Small, and it still sweats the details.
Version 2 is a rewrite. Everything below is tested in a real browser, on the readable and the minified build.
-
Snaps to real positions
Each card lands flush left, whatever its width. Near the end the track stops and the active card walks on to the last one — every card gets its dot and its turn.
-
Mouse, touch, pen, trackpad
Pointer Events for mouse and pen, native touch events for fingers so iPhone Safari never steals the swipe. A flick carries on with its speed, and a sideways two-finger swipe works on a trackpad.
-
Never fights the page
A vertical swipe still scrolls the page. Dragging over a link doesn't open it, and form fields inside cards keep their arrow keys.
-
Keyboard and screen readers
Arrow keys, Home and End, a focus that brings its card into view, labelled buttons and dots, and a quiet "Slide 2 of 5" announcement.
-
Keeps itself in sync
It re-measures when images or fonts load and when the layout changes, not only on window resize. No hard-coded slide width anywhere.
-
Plays well with others
One global name, an API with
goTo()anddestroy(), aslider:changeevent — and v1 markup keeps working as it is.
Add it to your site.
Three pieces: the markup, a little CSS and the script. Put several sliders on one page — each runs on its own.
-
Markup
The script looks for
data-sliderwith a viewport and a track inside. Every child of the track is a slide. Arrows and dots are optional.HTML <section class="slider" data-slider aria-label="Recent work"> <div class="slider-head"> <h2>Recent work</h2> <!-- Optional: arrows --> <div class="slider-arrows"> <button class="slider-btn" data-slider-prev aria-label="Previous slide">←</button> <button class="slider-btn" data-slider-next aria-label="Next slide">→</button> </div> </div> <div class="slider-viewport" data-slider-viewport> <ul class="slider-track" data-slider-track> <li class="slider-card">Card 1</li> <li class="slider-card">Card 2</li> <li class="slider-card">Card 3</li> <li class="slider-card">Card 4</li> </ul> </div> <!-- Optional: the script fills this with dots --> <div class="slider-pagination" data-slider-dots></div> </section> -
CSS
Base styles to restyle freely.
.slider-cardismax-contentwide — give it a fixed width for uniform cards.CSS /*! LeggoSlider v2.1 — base styles | leggo.dev/tools/slider | MIT ───────────────────────────────────────────────────────────────── What to change where: gap between cards ............. .slider-track → gap card width .................... .slider-card → width (max-content = fits its content) arrow buttons ................. .slider-btn → size, border, colours dots .......................... .slider-dot → size, colour; current one is .is-active dark background ............... .bg-dark … → dot and arrow colours No z-index is used. ───────────────────────────────────────────────────────────────── */ .slider-viewport { overflow: hidden; touch-action: pan-y; /* horizontal swipes are ours, vertical ones scroll the page */ cursor: grab; -webkit-user-select: none; user-select: none; } [data-slider-dragging="true"] .slider-viewport { cursor: grabbing; } .slider-viewport:focus-visible { outline: 2px solid #2B5CFF; outline-offset: 4px; border-radius: 4px; } .slider-track { display: flex; gap: 1.25rem; margin: 0; padding: 0; list-style: none; will-change: transform; } .slider-card { flex: 0 0 auto; width: max-content; /* each card is as wide as its content */ max-width: min(80vw, 24rem); } .slider-card img { -webkit-user-drag: none; } /* Arrows */ .slider-arrows { display: flex; gap: 0.5rem; } .slider-btn { display: grid; place-items: center; width: 2.75rem; height: 2.75rem; padding: 0; border: 1px solid currentColor; border-radius: 100px; background: transparent; color: inherit; font: inherit; line-height: 1; cursor: pointer; transition: background-color 0.2s ease, color 0.2s ease, opacity 0.2s ease; } .slider-btn:hover { background-color: #0D1109; color: #FFFFFF; } .slider-btn:focus-visible { outline: 2px solid #2B5CFF; outline-offset: 2px; } .slider-btn.is-disabled { opacity: 0.25; cursor: default; pointer-events: none; } /* Dots */ .slider-pagination { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.25rem; margin-top: 1.5rem; } .slider-pagination[hidden] { display: none; } /* The dot is 10px, the hit area 24px — big enough for a finger and WCAG 2.5.8. */ .slider-dot { position: relative; width: 1.5rem; height: 1.5rem; padding: 0; border: 0; background: none; cursor: pointer; } .slider-dot::before { content: ""; position: absolute; inset: 0; width: 0.625rem; height: 0.625rem; margin: auto; border-radius: 50%; background-color: rgba(13, 17, 9, 0.22); transition: background-color 0.3s ease, transform 0.3s ease; } .slider-dot:hover::before { background-color: rgba(13, 17, 9, 0.5); } .slider-dot.is-active::before { background-color: #0D1109; transform: scale(1.3); } .slider-dot:focus-visible { outline: 2px solid #2B5CFF; outline-offset: 0; border-radius: 50%; } /* Nothing to scroll — no arrows or dots needed */ [data-slider-static="true"] .slider-arrows { visibility: hidden; } /* Dark background */ .bg-dark .slider-dot::before { background-color: rgba(255, 255, 255, 0.25); } .bg-dark .slider-dot.is-active::before { background-color: #FFFFFF; } .bg-dark .slider-btn:hover { background-color: #FFFFFF; color: #0D1109; } @media (prefers-reduced-motion: reduce) { .slider-track, .slider-btn, .slider-dot::before { transition-duration: 0.01ms !important; } } -
JavaScript
Load it once, with
defer. It starts every slider on the page by itself.JavaScript /*! LeggoSlider v2.2 | leggo.dev/tools/slider | MIT */ ((t,e)=>{"use strict";const i={speed:500,easing:"cubic-bezier(0.25, 0.46, 0.45, 0.94)",threshold:40,labels:{region:"Carousel",prev:"Previous slide",next:"Next slide",dots:"Choose slide",dot:"Go to slide %n",status:"Slide %n of %t"}},s="[data-slider], .slider-instance",r=new WeakMap,o=t.matchMedia("(prefers-reduced-motion: reduce)"),h='input, textarea, select, [contenteditable=""], [contenteditable="true"]';let a=0;class n{#t;#e;#i;#s;#r=!1;#o;#h;#a;#n;#l=[];#d=[];#c=[];#u=!0;#p=0;#g=0;#v=0;#b;#x=null;#m=null;#f=!1;#y=0;#w=0;#k=null;#A=new Map;#T={};constructor(t,e={}){if(!t)throw new Error("LeggoSlider: root element not passed.");const s=r.get(t);if(s)return s;if(this.#t=t,this.#e=t.querySelector("[data-slider-viewport], .slider-viewport"),this.#i=t.querySelector("[data-slider-track], .slider-track"),this.#s=this.#i?.getAttribute("style")??null,!this.#e||!this.#i)throw new Error("LeggoSlider: viewport or track missing.");this.#b={...i,...this.#M(t),...e,labels:{...i.labels,...e.labels||{}}},this.#o=this.#S(t.querySelector("[data-slider-prev], .prev-btn")),this.#h=this.#S(t.querySelector("[data-slider-next], .next-btn")),this.#a=this.#S(t.querySelector("[data-slider-dots], .slider-pagination")),this.#r=!!this.#a?.hidden,this.#E(),this.#C(),this.refresh(),r.set(t,this),this.#B(t,"data-slider-ready","")}get index(){return this.#g}get length(){return this.#l.length}next(){this.goTo(this.#g+1)}prev(){this.goTo(this.#g-1)}goTo(t,{animate:e=!0,announce:i=!0}={}){const s=Math.max(0,Math.min(this.#l.length-1,t)),r=s!==this.#g;this.#g=s,this.#L(this.#c[s]??0,e),this.#D(),r&&(i&&this.#I(),this.#X("slider:change",{index:s,slide:this.#l[s]||null}))}refresh(){this.#l.forEach(t=>this.#k?.unobserve(t)),this.#l=Array.from(this.#i.children).filter(t=>1===t.nodeType),this.#l.forEach(t=>this.#k?.observe(t)),this.#q(),this.#N(),this.#g=Math.min(this.#g,Math.max(0,this.#l.length-1)),this.#L(this.#c[this.#g]??0,!1),this.#D()}destroy(){this.#w&&cancelAnimationFrame(this.#w),clearTimeout(this.#y),this.#k?.disconnect(),this.#O().forEach(([t,e,i,s])=>t?.removeEventListener(e,i,s)),this.#d.forEach(t=>t.remove()),this.#d=[],this.#n?.remove(),this.#a&&(this.#a.hidden=this.#r),this.#i.style.transform="",this.#i.style.transition="",null!==this.#s||this.#i.getAttribute("style")||this.#i.removeAttribute("style");const t=["is-active","is-next","is-prev"];this.#l.forEach(e=>e.classList.remove(...t)),[this.#o,this.#h].forEach(t=>t?.classList.remove("is-disabled")),this.#A.forEach((t,e)=>{Object.entries(t).forEach(([t,i])=>{null===i?e.removeAttribute(t):e.setAttribute(t,i)})}),this.#A.clear(),r.delete(this.#t)}static get(t){return r.get(t)||null}static init(t,i=e){return Array.from(i.querySelectorAll(s),e=>{try{return new n(e,t)}catch(t){return null}}).filter(Boolean)}#M(t){const e={},i=(t,e,i)=>{const s=parseFloat(t);return Number.isNaN(s)?void 0:Math.min(i,Math.max(e,s))},s=i(t.dataset.sliderSpeed,0,5e3),r=i(t.dataset.sliderThreshold,0,400);return void 0!==s&&(e.speed=s),void 0!==r&&(e.threshold=r),e}#S(t){return!t||this.#i.contains(t)?null:t.closest(s)===this.#t?t:null}#B(t,e,i){if(!t)return;let s=this.#A.get(t);s||(s={},this.#A.set(t,s)),e in s||(s[e]=t.getAttribute(e)),t.getAttribute(e)!==i&&t.setAttribute(e,i)}#E(){const t=this.#b.labels,i=this.#t;if(i.hasAttribute("role")||/^(SECTION|ASIDE|NAV)$/.test(i.tagName)||this.#B(i,"role","region"),this.#B(i,"aria-roledescription","carousel"),!i.hasAttribute("aria-label")&&!i.hasAttribute("aria-labelledby")){const e=Array.from(i.querySelectorAll("h1,h2,h3,h4,h5,h6")).find(t=>!this.#i.contains(t));e?(e.id||this.#B(e,"id","leggo-slider-title-"+ ++a),this.#B(i,"aria-labelledby",e.id)):this.#B(i,"aria-label",t.region)}this.#i.id||this.#B(this.#i,"id","leggo-slider-track-"+ ++a),this.#e.hasAttribute("tabindex")||this.#B(this.#e,"tabindex","0"),"auto"===getComputedStyle(this.#e).touchAction&&this.#B(this.#e,"style",`${this.#e.getAttribute("style")||""};touch-action:pan-y`.replace(/^;/,"")),this.#F(this.#o,t.prev),this.#F(this.#h,t.next),this.#a&&(this.#a.hasAttribute("role")||this.#B(this.#a,"role","group"),this.#a.hasAttribute("aria-label")||this.#B(this.#a,"aria-label",t.dots)),this.#n=e.createElement("p"),this.#n.setAttribute("aria-live","polite"),this.#n.setAttribute("aria-atomic","true"),this.#n.style.cssText="position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0",i.appendChild(this.#n)}#F(t,e){t&&("BUTTON"!==t.tagName||t.hasAttribute("type")||this.#B(t,"type","button"),t.hasAttribute("aria-label")||/[a-z]{2,}/i.test(t.textContent)||this.#B(t,"aria-label",e),this.#B(t,"aria-controls",this.#i.id))}#O(){const t=this.#T,e=this.#e,i=this.#i;return[ [e,"pointerdown",t.down],[e,"pointermove",t.move],[e,"pointerup",t.up],[e,"pointercancel",t.up],[e,"lostpointercapture",t.up],[e,"touchstart",t.tstart,{passive:!0}],[e,"touchmove",t.tmove,{passive:!1}],[e,"touchend",t.tend],[e,"touchcancel",t.tend],[e,"wheel",t.wheel,{passive:!1}],[e,"scroll",t.scroll,{passive:!0}],[i,"click",t.click,!0],[i,"dragstart",t.dragstart],[i,"focusin",t.focusin],[this.#t,"keydown",t.key],[this.#o,"click",t.prev],[this.#h,"click",t.next],[this.#a,"click",t.dot] ]}#C(){const e=this.#T;e.down=this.#R.bind(this),e.move=this.#U.bind(this),e.up=this.#H.bind(this),e.tstart=this.#K.bind(this),e.tmove=this.#P.bind(this),e.tend=this.#W.bind(this),e.wheel=this.#Y.bind(this),e.key=this.#$.bind(this),e.focusin=this.#z.bind(this),e.prev=()=>this.prev(),e.next=()=>this.next(),e.dot=t=>{const e=t.target.closest("[data-slider-dot]");e&&this.#d.includes(e)&&this.goTo(this.#d.indexOf(e))},e.click=t=>{this.#f&&(t.preventDefault(),t.stopPropagation(),this.#f=!1)},e.dragstart=t=>t.preventDefault(),e.scroll=()=>{this.#e.scrollLeft&&(this.#e.scrollLeft=0)},this.#O().forEach(([t,e,i,s])=>t?.addEventListener(e,i,s)),"ResizeObserver"in t&&(this.#k=new ResizeObserver(()=>{this.#w||(this.#w=requestAnimationFrame(()=>{if(this.#w=0,this.#x)return;const t=this.#u;this.#q(),t===this.#u&&this.#d.length===this.#l.length||this.#N(),this.#L(this.#c[this.#g]??0,!1),this.#D()}))}),this.#k.observe(this.#e),this.#k.observe(this.#i))}#q(){const t=this.#i.getBoundingClientRect().left,e=parseFloat(getComputedStyle(this.#i).paddingLeft)||0;this.#p=Math.min(0,this.#e.clientWidth-this.#i.scrollWidth),this.#c=this.#l.map(i=>{const s=i.getBoundingClientRect().left-t-e;return Math.max(this.#p,Math.min(0,-s))}),this.#u=this.#p>-1,this.#B(this.#t,"data-slider-static",String(this.#u))}#L(t,e){this.#v=t;const i=this.#i.style;i.transition=e&&!o.matches&&this.#b.speed>0?`transform ${this.#b.speed}ms ${this.#b.easing}`:"none",i.transform=`translate3d(${t}px, 0, 0)`}#j(t){return t>0?.35*t:t<this.#p?this.#p+.35*(t-this.#p):t}#G(t,e){const i=this.#v+180*e,s=t<0?1:-1;let r=this.#c[0]??0;this.#c.forEach(t=>{Math.abs(t-i)<Math.abs(r-i)&&(r=t)});const o=this.#c.reduce((t,e,i)=>Math.abs(e-r)<1?t.concat(i):t,[]);let h;h=o.includes(this.#g)?Math.abs(t)>this.#b.threshold?this.#g+s:this.#g:s>0?o[0]:o[o.length-1],this.goTo(h,{announce:!1})}#V(t,e){this.#u||this.#x||e?.closest?.(h)||(this.#x={id:t.id,x0:t.x,y0:t.y,base:this.#v,axis:null,lastX:t.x,lastT:t.t,v:0},this.#f=!1)}#J(t,e){const i=this.#x;if(!i||t.id!==i.id)return!1;const s=t.x-i.x0,r=t.y-i.y0;if(!i.axis){if(Math.abs(s)<6&&Math.abs(r)<6)return!1;if(i.axis=Math.abs(s)>Math.abs(r)?"x":"y","y"===i.axis)return this.#x=null,!1;e?.(),this.#B(this.#t,"data-slider-dragging","true")}const o=Math.max(1,t.t-i.lastT);return i.v=.6*i.v+(t.x-i.lastX)/o*.4,i.lastX=t.x,i.lastT=t.t,this.#L(this.#j(i.base+s),!1),this.#v=i.base+s,!0}#Q(t,e){const i=this.#x;if(!i||t.id!==i.id)return;if(this.#x=null,"x"!==i.axis)return;this.#B(this.#t,"data-slider-dragging","false");const s=(e?i.lastX:t.x)-i.x0;Math.abs(s)>6&&(this.#f=!0),setTimeout(()=>{this.#f=!1},0),this.#v=this.#j(this.#v),this.#G(s,e?0:i.v)}#R(t){"touch"!==t.pointerType&&t.isPrimary&&0===t.button&&this.#V({id:"p"+t.pointerId,x:t.clientX,y:t.clientY,t:t.timeStamp},t.target)}#U(t){"touch"!==t.pointerType&&this.#J({id:"p"+t.pointerId,x:t.clientX,y:t.clientY,t:t.timeStamp},()=>{try{this.#e.setPointerCapture(t.pointerId)}catch(t){}})}#H(t){"touch"!==t.pointerType&&("lostpointercapture"===t.type&&t.target!==this.#e||this.#Q({id:"p"+t.pointerId,x:t.clientX},"pointercancel"===t.type))}#K(t){if(t.touches.length>1)return void(this.#x&&this.#Q({id:this.#x.id,x:this.#x.lastX},!0));const e=t.changedTouches[0];this.#V({id:"t"+e.identifier,x:e.clientX,y:e.clientY,t:t.timeStamp},t.target)}#Z(t){const e=this.#x&&[...t.changedTouches].find(t=>"t"+t.identifier===this.#x.id);return e&&{id:this.#x.id,x:e.clientX,y:e.clientY,t:t.timeStamp}}#P(t){const e=this.#Z(t);e&&this.#J(e)&&t.cancelable&&t.preventDefault()}#W(t){const e=this.#Z(t);e&&this.#Q(e,"touchcancel"===t.type)}#Y(t){this.#u||Math.abs(t.deltaX)<=Math.abs(t.deltaY)||(t.preventDefault(),this.#m||(this.#m={start:this.#v}),this.#v=Math.max(this.#p-80,Math.min(80,this.#v-t.deltaX)),this.#L(this.#j(this.#v),!1),this.#v=this.#j(this.#v),clearTimeout(this.#y),this.#y=setTimeout(()=>{const t=this.#v-this.#m.start;this.#m=null,this.#G(t,0)},140))}#$(t){if(t.defaultPrevented||t.altKey||t.ctrlKey||t.metaKey)return;if(t.target.closest(h))return;const e={ArrowLeft:this.#g-1,ArrowRight:this.#g+1,Home:0,End:this.#l.length-1};t.key in e&&("Home"!==t.key&&"End"!==t.key||t.target===this.#e)&&(t.preventDefault(),this.goTo(e[t.key]))}#z(t){const e=this.#l.find(e=>e.contains(t.target));if(!e)return;this.#e.scrollLeft=0;const i=this.#e.getBoundingClientRect(),s=e.getBoundingClientRect();s.left>=i.left-1&&s.right<=i.right+1||this.goTo(this.#l.indexOf(e),{announce:!1})}#N(){this.#a&&(this.#a.querySelectorAll("[data-slider-dot], .slider-dot").forEach(t=>t.remove()),this.#d=this.#u?[]:this.#l.map((t,i)=>{const s=e.createElement("button");return s.type="button",s.className="slider-dot",s.setAttribute("data-slider-dot",""),s.setAttribute("aria-label",this.#b.labels.dot.replace("%n",i+1)),this.#a.appendChild(s),s}),this.#a.hidden=this.#u)}#D(){const t=this.#l.length-1;this.#_(this.#o,this.#u||this.#g<=0),this.#_(this.#h,this.#u||this.#g>=t),this.#d.forEach((t,e)=>{const i=e===this.#g;t.classList.toggle("is-active",i),i?t.setAttribute("aria-current","true"):t.removeAttribute("aria-current")});const e=this.#g;this.#l.forEach((t,i)=>{t.classList.toggle("is-active",i===e),t.classList.toggle("is-next",i===e+1),t.classList.toggle("is-prev",i===e-1)})}#_(t,e){t&&(t.classList.toggle("is-disabled",e),this.#B(t,"aria-disabled",String(e)))}#I(){const t=this.#b.labels.status.replace("%n",this.#g+1).replace("%t",this.#l.length);this.#n.textContent="",requestAnimationFrame(()=>{this.#n&&(this.#n.textContent=t)})}#X(t,e){this.#t.dispatchEvent(new CustomEvent(t,{bubbles:!0,detail:{slider:this,...e}}))}}"loading"===e.readyState?e.addEventListener("DOMContentLoaded",()=>n.init(),{once:!0}):n.init(),t.LeggoSlider=n})(window,document);/*! LeggoSlider v2.2 — drag-and-snap carousel, vanilla JS | leggo.dev/tools/slider | MIT */ ((window, document) => { 'use strict'; const DEFAULTS = { speed: 500, // ms per transition easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)', threshold: 40, // px: a shorter slow drag springs back labels: { region: 'Carousel', prev: 'Previous slide', next: 'Next slide', dots: 'Choose slide', dot: 'Go to slide %n', status: 'Slide %n of %t' } }; // data-* attributes are the main hooks. The v1 classes still work, so old markup needs no changes. const SEL = { root: '[data-slider], .slider-instance', viewport: '[data-slider-viewport], .slider-viewport', track: '[data-slider-track], .slider-track', prev: '[data-slider-prev], .prev-btn', next: '[data-slider-next], .next-btn', dots: '[data-slider-dots], .slider-pagination' }; const instances = new WeakMap(); const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); const FORM_FIELD = 'input, textarea, select, [contenteditable=""], [contenteditable="true"]'; const DRAG_START = 6; // px of movement before we decide: sideways drag or page scroll const RUBBER = 0.35; // resistance past the ends let uid = 0; class LeggoSlider { #root; #viewport; #track; #trackStyle; #dotsHidden = false; #prev; #next; #dotsBox; #live; #slides = []; #dots = []; #pos = []; // track offset for each slide (px, <= 0), clamped to the ends #static = true; // every slide fits — nothing to scroll #min = 0; #index = 0; #x = 0; #opt; #drag = null; #wheel = null; #suppressClick = false; #wheelTimer = 0; #raf = 0; #ro = null; #touched = new Map(); // element -> { attribute: original value | null } #h = {}; // handler references, so removeEventListener really removes them constructor(root, options = {}) { if (!root) throw new Error('LeggoSlider: root element not passed.'); const existing = instances.get(root); if (existing) return existing; this.#root = root; this.#viewport = root.querySelector(SEL.viewport); this.#track = root.querySelector(SEL.track); this.#trackStyle = this.#track?.getAttribute('style') ?? null; if (!this.#viewport || !this.#track) throw new Error('LeggoSlider: viewport or track missing.'); this.#opt = { ...DEFAULTS, ...this.#readDataset(root), ...options, labels: { ...DEFAULTS.labels, ...(options.labels || {}) } }; // Controls are looked up outside the track only: buttons inside cards belong to the content. this.#prev = this.#own(root.querySelector(SEL.prev)); this.#next = this.#own(root.querySelector(SEL.next)); this.#dotsBox = this.#own(root.querySelector(SEL.dots)); this.#dotsHidden = !!this.#dotsBox?.hidden; this.#setupA11y(); this.#bind(); this.refresh(); instances.set(root, this); this.#set(root, 'data-slider-ready', ''); } /* ── Public API ────────────────────────────────────────── */ get index() { return this.#index; } get length() { return this.#slides.length; } next() { this.goTo(this.#index + 1); } prev() { this.goTo(this.#index - 1); } goTo(index, { animate = true, announce = true } = {}) { const i = Math.max(0, Math.min(this.#slides.length - 1, index)); const changed = i !== this.#index; this.#index = i; // Near the end the track stops moving, but the active card keeps going: // every card can become active and has its own dot. this.#moveTo(this.#pos[i] ?? 0, animate); this.#updateUI(); if (changed) { if (announce) this.#announce(); this.#emit('slider:change', { index: i, slide: this.#slides[i] || null }); } } /** Re-read the cards — call it after adding or removing slides from code. */ refresh() { this.#slides.forEach((sl) => this.#ro?.unobserve(sl)); this.#slides = Array.from(this.#track.children).filter((el) => el.nodeType === 1); // The track is stretched to its parent, so its box doesn't change when cards do — watch the cards. this.#slides.forEach((sl) => this.#ro?.observe(sl)); this.#measure(); this.#buildDots(); this.#index = Math.min(this.#index, Math.max(0, this.#slides.length - 1)); this.#moveTo(this.#pos[this.#index] ?? 0, false); this.#updateUI(); } destroy() { if (this.#raf) cancelAnimationFrame(this.#raf); clearTimeout(this.#wheelTimer); this.#ro?.disconnect(); this.#events().forEach(([el, type, fn, opts]) => el?.removeEventListener(type, fn, opts)); this.#dots.forEach((d) => d.remove()); this.#dots = []; this.#live?.remove(); if (this.#dotsBox) this.#dotsBox.hidden = this.#dotsHidden; this.#track.style.transform = ''; this.#track.style.transition = ''; if (this.#trackStyle === null && !this.#track.getAttribute('style')) this.#track.removeAttribute('style'); const cls = ['is-active', 'is-next', 'is-prev']; this.#slides.forEach((s) => s.classList.remove(...cls)); [this.#prev, this.#next].forEach((b) => b?.classList.remove('is-disabled')); // Put every attribute back exactly as we found it. 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 LeggoSlider(root, options); } catch (e) { return null; } }).filter(Boolean); } /* ── Setup ─────────────────────────────────────────────── */ #readDataset(root) { const out = {}; const num = (raw, min, max) => { const n = parseFloat(raw); return Number.isNaN(n) ? undefined : Math.min(max, Math.max(min, n)); }; const speed = num(root.dataset.sliderSpeed, 0, 5000); const threshold = num(root.dataset.sliderThreshold, 0, 400); if (speed !== undefined) out.speed = speed; if (threshold !== undefined) out.threshold = threshold; return out; } // An element is ours if it sits inside the root but not inside the track (that's card content) // and doesn't belong to a nested slider. #own(el) { if (!el || this.#track.contains(el)) return null; return el.closest(SEL.root) === this.#root ? el : null; } // Remember the original value before the first write, so destroy() can restore it. #set(el, name, value) { if (!el) return; let saved = this.#touched.get(el); if (!saved) { saved = {}; this.#touched.set(el, saved); } if (!(name in saved)) saved[name] = el.getAttribute(name); if (el.getAttribute(name) !== value) el.setAttribute(name, value); } #setupA11y() { const L = this.#opt.labels; const root = this.#root; if (!root.hasAttribute('role') && !/^(SECTION|ASIDE|NAV)$/.test(root.tagName)) this.#set(root, 'role', 'region'); this.#set(root, 'aria-roledescription', 'carousel'); if (!root.hasAttribute('aria-label') && !root.hasAttribute('aria-labelledby')) { const heading = Array.from(root.querySelectorAll('h1,h2,h3,h4,h5,h6')).find((hd) => !this.#track.contains(hd)); if (heading) { if (!heading.id) this.#set(heading, 'id', `leggo-slider-title-${++uid}`); this.#set(root, 'aria-labelledby', heading.id); } else { this.#set(root, 'aria-label', L.region); } } if (!this.#track.id) this.#set(this.#track, 'id', `leggo-slider-track-${++uid}`); // The viewport takes focus for the arrow keys when the cards have no links of their own. if (!this.#viewport.hasAttribute('tabindex')) this.#set(this.#viewport, 'tabindex', '0'); // Without pan-y the browser claims every gesture and fires pointercancel, so swipes die. // Vertical page scrolling stays with the finger. if (getComputedStyle(this.#viewport).touchAction === 'auto') { this.#set(this.#viewport, 'style', `${this.#viewport.getAttribute('style') || ''};touch-action:pan-y`.replace(/^;/, '')); } this.#labelArrow(this.#prev, L.prev); this.#labelArrow(this.#next, L.next); if (this.#dotsBox) { if (!this.#dotsBox.hasAttribute('role')) this.#set(this.#dotsBox, 'role', 'group'); if (!this.#dotsBox.hasAttribute('aria-label')) this.#set(this.#dotsBox, 'aria-label', L.dots); } // Our own live region: announces "Slide 2 of 5" only on deliberate navigation. this.#live = document.createElement('p'); this.#live.setAttribute('aria-live', 'polite'); this.#live.setAttribute('aria-atomic', 'true'); this.#live.style.cssText = 'position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0'; root.appendChild(this.#live); } #labelArrow(btn, label) { if (!btn) return; if (btn.tagName === 'BUTTON' && !btn.hasAttribute('type')) this.#set(btn, 'type', 'button'); // An arrow glyph is not a name. If there's no real text, give the button a label. if (!btn.hasAttribute('aria-label') && !/[a-z]{2,}/i.test(btn.textContent)) this.#set(btn, 'aria-label', label); this.#set(btn, 'aria-controls', this.#track.id); } // Every listener in one list, so bind() and destroy() can never drift apart. #events() { const h = this.#h, v = this.#viewport, t = this.#track; return [ [v, 'pointerdown', h.down], [v, 'pointermove', h.move], [v, 'pointerup', h.up], [v, 'pointercancel', h.up], [v, 'lostpointercapture', h.up], [v, 'touchstart', h.tstart, { passive: true }], [v, 'touchmove', h.tmove, { passive: false }], [v, 'touchend', h.tend], [v, 'touchcancel', h.tend], [v, 'wheel', h.wheel, { passive: false }], [v, 'scroll', h.scroll, { passive: true }], [t, 'click', h.click, true], [t, 'dragstart', h.dragstart], [t, 'focusin', h.focusin], [this.#root, 'keydown', h.key], [this.#prev, 'click', h.prev], [this.#next, 'click', h.next], [this.#dotsBox, 'click', h.dot] ]; } #bind() { const h = this.#h; h.down = this.#onDown.bind(this); h.move = this.#onMove.bind(this); h.up = this.#onUp.bind(this); h.tstart = this.#onTouchStart.bind(this); h.tmove = this.#onTouchMove.bind(this); h.tend = this.#onTouchEnd.bind(this); h.wheel = this.#onWheel.bind(this); h.key = this.#onKey.bind(this); h.focusin = this.#onFocusIn.bind(this); h.prev = () => this.prev(); h.next = () => this.next(); h.dot = (e) => { const dot = e.target.closest('[data-slider-dot]'); if (dot && this.#dots.includes(dot)) this.goTo(this.#dots.indexOf(dot)); }; // A release after a drag is not a click — otherwise letting go over a link leaves the page. h.click = (e) => { if (!this.#suppressClick) return; e.preventDefault(); e.stopPropagation(); this.#suppressClick = false; }; h.dragstart = (e) => e.preventDefault(); // no ghost image of a picture or link while dragging // overflow:hidden still scrolls when something inside gets focus — keep it pinned at zero. h.scroll = () => { if (this.#viewport.scrollLeft) this.#viewport.scrollLeft = 0; }; // Fingers go through touch events: iOS Safari cancels a pointer drag as soon as it // decides the gesture is a scroll, and only a cancelable touchmove can stop that. this.#events().forEach(([el, type, fn, opts]) => el?.addEventListener(type, fn, opts)); // Widths 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; if (this.#drag) return; const wasStatic = this.#static; this.#measure(); if (wasStatic !== this.#static || this.#dots.length !== this.#slides.length) this.#buildDots(); this.#moveTo(this.#pos[this.#index] ?? 0, false); this.#updateUI(); }); }); this.#ro.observe(this.#viewport); this.#ro.observe(this.#track); } } /* ── Geometry ──────────────────────────────────────────── */ #measure() { // Positions come from getBoundingClientRect relative to the track: the transform moves // the track and its cards alike, so the difference doesn't depend on it. const base = this.#track.getBoundingClientRect().left; const padL = parseFloat(getComputedStyle(this.#track).paddingLeft) || 0; this.#min = Math.min(0, this.#viewport.clientWidth - this.#track.scrollWidth); this.#pos = this.#slides.map((s) => { const left = s.getBoundingClientRect().left - base - padL; return Math.max(this.#min, Math.min(0, -left)); }); this.#static = this.#min > -1; this.#set(this.#root, 'data-slider-static', String(this.#static)); } #moveTo(x, animate) { this.#x = x; const t = this.#track.style; t.transition = animate && !reducedMotion.matches && this.#opt.speed > 0 ? `transform ${this.#opt.speed}ms ${this.#opt.easing}` : 'none'; t.transform = `translate3d(${x}px, 0, 0)`; } #rubber(x) { if (x > 0) return x * RUBBER; if (x < this.#min) return this.#min + (x - this.#min) * RUBBER; return x; } // Where to land after a gesture. Cards near the end share one track position, // so a swipe there moves the active card instead of the track. #settle(delta, velocity) { const projected = this.#x + velocity * 180; const dir = delta < 0 ? 1 : -1; let best = this.#pos[0] ?? 0; this.#pos.forEach((p) => { if (Math.abs(p - projected) < Math.abs(best - projected)) best = p; }); const group = this.#pos.reduce((acc, p, i) => (Math.abs(p - best) < 1 ? acc.concat(i) : acc), []); let i; if (group.includes(this.#index)) { // Same resting place: a short confident gesture still steps one card. i = Math.abs(delta) > this.#opt.threshold ? this.#index + dir : this.#index; } else { i = dir > 0 ? group[0] : group[group.length - 1]; } // goTo moves the track even for the same index — that's what springs the card back. this.goTo(i, { announce: false }); } /* ── Gestures ──────────────────────────────────────────── */ // One drag, whatever started it: p = { id, x, y, t }. #start(p, target) { if (this.#static || this.#drag) return; if (target?.closest?.(FORM_FIELD)) return; this.#drag = { id: p.id, x0: p.x, y0: p.y, base: this.#x, axis: null, lastX: p.x, lastT: p.t, v: 0 }; this.#suppressClick = false; } // Returns true while the drag is horizontal, so the caller can stop the page from scrolling. #move(p, capture) { const d = this.#drag; if (!d || p.id !== d.id) return false; const dx = p.x - d.x0; const dy = p.y - d.y0; if (!d.axis) { if (Math.abs(dx) < DRAG_START && Math.abs(dy) < DRAG_START) return false; d.axis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y'; if (d.axis === 'y') { this.#drag = null; return false; } // the user is scrolling the page capture?.(); this.#set(this.#root, 'data-slider-dragging', 'true'); } const dt = Math.max(1, p.t - d.lastT); d.v = d.v * 0.6 + ((p.x - d.lastX) / dt) * 0.4; // smoothed velocity, px/ms d.lastX = p.x; d.lastT = p.t; this.#moveTo(this.#rubber(d.base + dx), false); this.#x = d.base + dx; return true; } #end(p, cancelled) { const d = this.#drag; if (!d || p.id !== d.id) return; this.#drag = null; if (d.axis !== 'x') return; this.#set(this.#root, 'data-slider-dragging', 'false'); const delta = (cancelled ? d.lastX : p.x) - d.x0; if (Math.abs(delta) > DRAG_START) this.#suppressClick = true; // If no click follows (released outside a card), the flag must not swallow the next real one. setTimeout(() => { this.#suppressClick = false; }, 0); this.#x = this.#rubber(this.#x); this.#settle(delta, cancelled ? 0 : d.v); } /* Mouse and pen: pointer events. Touch pointers are left to the touch handlers below. */ #onDown(e) { if (e.pointerType === 'touch' || !e.isPrimary || e.button !== 0) return; this.#start({ id: 'p' + e.pointerId, x: e.clientX, y: e.clientY, t: e.timeStamp }, e.target); } #onMove(e) { if (e.pointerType === 'touch') return; this.#move({ id: 'p' + e.pointerId, x: e.clientX, y: e.clientY, t: e.timeStamp }, () => { try { this.#viewport.setPointerCapture(e.pointerId); } catch (_) { /* pointer already released */ } }); } #onUp(e) { if (e.pointerType === 'touch') return; // lostpointercapture bubbles up from children: only our own capture ending counts. if (e.type === 'lostpointercapture' && e.target !== this.#viewport) return; this.#end({ id: 'p' + e.pointerId, x: e.clientX }, e.type === 'pointercancel'); } /* Fingers: touch events. */ #onTouchStart(e) { if (e.touches.length > 1) { // a second finger means pinch-zoom: let go if (this.#drag) this.#end({ id: this.#drag.id, x: this.#drag.lastX }, true); return; } const t = e.changedTouches[0]; this.#start({ id: 't' + t.identifier, x: t.clientX, y: t.clientY, t: e.timeStamp }, e.target); } #finger(e) { const t = this.#drag && [...e.changedTouches].find((c) => 't' + c.identifier === this.#drag.id); return t && { id: this.#drag.id, x: t.clientX, y: t.clientY, t: e.timeStamp }; } #onTouchMove(e) { const p = this.#finger(e); if (p && this.#move(p) && e.cancelable) e.preventDefault(); } #onTouchEnd(e) { const p = this.#finger(e); if (p) this.#end(p, e.type === 'touchcancel'); } // Horizontal trackpad swipe. Vertical page scrolling is left alone. #onWheel(e) { if (this.#static || Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; e.preventDefault(); if (!this.#wheel) this.#wheel = { start: this.#x }; this.#x = Math.max(this.#min - 80, Math.min(80, this.#x - e.deltaX)); this.#moveTo(this.#rubber(this.#x), false); this.#x = this.#rubber(this.#x); clearTimeout(this.#wheelTimer); this.#wheelTimer = setTimeout(() => { const delta = this.#x - this.#wheel.start; this.#wheel = null; this.#settle(delta, 0); }, 140); } #onKey(e) { if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return; if (e.target.closest(FORM_FIELD)) return; const map = { ArrowLeft: this.#index - 1, ArrowRight: this.#index + 1, Home: 0, End: this.#slides.length - 1 }; if (!(e.key in map)) return; // The page needs Home/End too — take them only while the viewport itself has focus. if ((e.key === 'Home' || e.key === 'End') && e.target !== this.#viewport) return; e.preventDefault(); this.goTo(map[e.key]); } // Tabbing to a link in a card that's out of view brings that card in, instead of hiding the focus. #onFocusIn(e) { const slide = this.#slides.find((s) => s.contains(e.target)); if (!slide) return; this.#viewport.scrollLeft = 0; const vp = this.#viewport.getBoundingClientRect(); const r = slide.getBoundingClientRect(); if (r.left >= vp.left - 1 && r.right <= vp.right + 1) return; this.goTo(this.#slides.indexOf(slide), { announce: false }); } /* ── UI ────────────────────────────────────────────────── */ #buildDots() { if (!this.#dotsBox) return; // Dots saved into the page HTML by a page builder are a copy of an earlier run. Replace them. this.#dotsBox.querySelectorAll('[data-slider-dot], .slider-dot').forEach((d) => d.remove()); this.#dots = this.#static ? [] : this.#slides.map((_, i) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'slider-dot'; b.setAttribute('data-slider-dot', ''); b.setAttribute('aria-label', this.#opt.labels.dot.replace('%n', i + 1)); this.#dotsBox.appendChild(b); return b; }); this.#dotsBox.hidden = this.#static; } #updateUI() { const last = this.#slides.length - 1; this.#toggleArrow(this.#prev, this.#static || this.#index <= 0); this.#toggleArrow(this.#next, this.#static || this.#index >= last); this.#dots.forEach((d, i) => { const on = i === this.#index; d.classList.toggle('is-active', on); on ? d.setAttribute('aria-current', 'true') : d.removeAttribute('aria-current'); }); const a = this.#index; this.#slides.forEach((s, i) => { s.classList.toggle('is-active', i === a); s.classList.toggle('is-next', i === a + 1); s.classList.toggle('is-prev', i === a - 1); }); } // aria-disabled rather than disabled: a disabled button drops focus and the keyboard user gets lost. #toggleArrow(btn, off) { if (!btn) return; btn.classList.toggle('is-disabled', off); this.#set(btn, 'aria-disabled', String(off)); } #announce() { const text = this.#opt.labels.status.replace('%n', this.#index + 1).replace('%t', this.#slides.length); this.#live.textContent = ''; requestAnimationFrame(() => { if (this.#live) this.#live.textContent = text; }); } #emit(name, detail) { this.#root.dispatchEvent(new CustomEvent(name, { bubbles: true, detail: { slider: this, ...detail } })); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => LeggoSlider.init(), { once: true }); } else { LeggoSlider.init(); } window.LeggoSlider = LeggoSlider; })(window, document);
Hooks and API.
Nothing to configure — style the state classes, and reach for the API only when you need it.
| Name | Where | What it does |
|---|---|---|
.is-active
|
card, dot | The card at the left edge and its dot. |
.is-next / .is-prev
|
card | The cards either side of the active one. |
.is-disabled
|
arrow | The track is at that end. The button also gets aria-disabled="true" and keeps its focus. |
data-slider-dragging
|
slider |
"true" while someone drags — handy for a grabbing cursor or pausing hover effects. |
data-slider-static
|
slider |
"true" when every card fits. Arrows hide, dots aren't drawn. |
data-slider-speed
|
slider | Transition time in ms, default 500. 0 jumps. |
data-slider-threshold
|
slider | How far a slow drag must go to change slides, default 40px. |
LeggoSlider.get(el)
|
JS | The instance for a slider element, then .goTo(i), .next(), .prev(), .index, .length. |
.refresh() / .destroy()
|
JS | Re-read the cards after adding some; take the slider off and leave the markup exactly as it was. |
LeggoSlider.init(options, scope)
|
JS | Start sliders added later, e.g. after an AJAX load. Calling it twice is safe. |
slider:change
|
event | Fires on the slider with detail.index and detail.slide. It bubbles. |
Questions about the slider.
Does it need jQuery or any library?
No. It's plain JavaScript with no dependencies — about 4 KB gzipped. Drop the file in with defer and it starts every slider on the page.
Can I have several sliders on one page?
Yes. Each data-slider block runs on its own, with its own arrows, dots and position. Sliders added later — after an AJAX load, say — start with LeggoSlider.init().
Does it work in Webflow, WordPress and Builderius?
Yes. It only needs the markup, the CSS and the script, so it fits any builder or theme. In Builderius, paste the markup through the HTML → Builderius converter and add the script as a JS snippet.
I use the old version. Do I have to change my HTML?
No. The v1 classes — .slider-instance, .slider-track, .prev-btn, .next-btn, .slider-pagination — still work. Swap the script and CSS, and dots saved into the page by a builder are replaced automatically.
Is it accessible?
It's built for it: the slider is announced as a carousel with a name, arrows and dots are labelled buttons, arrow keys and Home/End work, tabbing into a hidden card brings it into view, and the current slide is announced politely. With reduced motion turned on, it moves without animation.
Why no autoplay or infinite loop?
On purpose. Autoplay moves content while people are reading it and is a common accessibility complaint; a loop hides where the list ends. If a project really needs either, goTo() and the slider:change event make it a few lines of code.
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.