Slider
Drag-and-snap carousel, in pure JavaScript
Carousel
Vanilla JS
Drag & Swipe
A tiny, dependency-free carousel that snaps one card at a time . It runs on a CSS transform, supports mouse drag and touch swipe, and builds its own arrows and dots. The trick that makes it flexible: cards size to their own content — no fixed slide width required — and the script snaps to each card's real position, so a row of differently-sized cards still lands cleanly. Drop several on one page; each .slider-instance runs on its own.
Live demos
Drag with the mouse, swipe on touch, use the arrows, or click the dots. The first demo mixes card widths on purpose.
1 · HTML structure
Wrap each carousel in .slider-instance . Inside it the script looks for a .slider-viewport holding a .slider-track of .slider-card items. Arrows and the dots container are optional — add them only if you want them.
<section class="slider-instance">
<!-- Optional: heading + arrows -->
<div class="heading-wrapper">
<h2>Project Title</h2>
<div class="slider-arrows">
<button class="slider-btn prev-btn">←</button>
<button class="slider-btn next-btn">→</button>
</div>
</div>
<div class="slider-viewport">
<ul class="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"></div>
</section>
2 · CSS (required)
Add these once. .slider-card uses width: max-content so each card is exactly as wide as its content — give a card a fixed width only if you want a uniform footprint.
/*! Slider by leggo.dev v1.0 */
.slider-viewport { overflow: hidden; cursor: grab; user-select: none; }
.slider-viewport:active { cursor: grabbing; }
.slider-track {
display: flex;
gap: 1.25rem;
list-style: none;
margin: 0;
padding: 0;
will-change: transform;
}
/* Content-driven width — each card fits its own content */
.slider-card { flex: 0 0 auto; width: max-content; }
/* Arrows */
.slider-btn { cursor: pointer; }
.slider-btn.is-disabled { opacity: .25; pointer-events: none; }
/* Pagination dots */
.slider-pagination { display: flex; justify-content: center; gap: 12px; margin-top: 30px; }
.slider-dot {
width: 10px; height: 10px; border-radius: 50%;
background: rgba(0, 0, 0, 0.2);
border: none; padding: 0; cursor: pointer;
transition: all 0.3s ease;
}
.slider-dot.is-active { background: #333; transform: scale(1.3); }
/* Dark backgrounds */
.bg-dark .slider-dot { background: rgba(255, 255, 255, 0.2); }
.bg-dark .slider-dot.is-active { background: #fff; }
3 · Class hooks
There's nothing to configure — the script auto-runs. Instead it adds these state classes you can style however you like.
| Class | Added to | When |
|---|---|---|
| .is-active | the current .slider-card | The card snapped to the left edge. Style it to highlight the focused slide. |
| .is-next | a .slider-card | The card immediately after the active one. |
| .is-prev | a .slider-card | The card immediately before the active one. |
| .is-disabled | .prev-btn / .next-btn | The track has reached that end — the arrow is dimmed and non-clickable. |
| .is-active | a .slider-dot | The dot matching the current card. |
4 · The library (v1.0)
Drop this in once (or in your global JS). It finds every .slider-instance , wires drag / swipe / arrows / dots, and snaps to each card's real position — so variable-width cards work without any extra config.
Good to know:
- Snapping uses each card's actual offsetLeft , so mixed-width cards always land flush.
- A drag never fires a stray click on links or buttons inside a card.
- It re-measures on resize, so responsive card widths stay in sync.
/*! Slider by leggo.dev v1.0 — multi-instance, drag + snap, content-driven widths */
document.addEventListener("DOMContentLoaded",(()=>{document.querySelectorAll(".slider-instance").forEach((e=>{const t=e.querySelector(".slider-track"),n=e.querySelector(".slider-viewport"),r=e.querySelector(".next-btn"),s=e.querySelector(".prev-btn"),a=e.querySelector(".slider-pagination"),l=Array.from(e.querySelectorAll(".slider-card"));if(!t||!n)return;let i=!1,o=0,d=0,c=0,u=0;const f=[];a&&l.forEach(((_,t)=>{const n=document.createElement("button");n.classList.add("slider-dot"),a.appendChild(n),f.push(n),n.onclick=()=>g(t)}));const p=()=>l[0].offsetWidth+parseFloat(getComputedStyle(t).gap||0),m=()=>({min:-(t.scrollWidth-n.offsetWidth),max:0}),g=e=>{const r=p(),s=m();let a=-e*r;as.max&&(a=s.max),d=a,c=d,u=Math.round(Math.abs(d)/r),t.style.transition="transform 0.5s cubic-bezier(0.25,0.46,0.45,0.94)",t.style.transform=`translate3d(${d}px,0,0)`,v()},v=()=>{const e=m();s&&s.classList.toggle("is-disabled",d>=-5),r&&r.classList.toggle("is-disabled",de.classList.toggle("is-active",t===u))),l.forEach(((e,t)=>{e.classList.remove("is-active","is-next","is-prev"),t===u?e.classList.add("is-active"):t===u+1?e.classList.add("is-next"):t===u-1&&e.classList.add("is-prev")}))},h=e=>{if(!i)return;const r=(e.type.includes("touch")?e.touches[0].clientX:e.clientX)-o,s=c+r,a=m();d=s>a.max||s{i=!1,window.removeEventListener("mousemove",h),window.removeEventListener("mouseup",E),window.removeEventListener("touchmove",h),window.removeEventListener("touchend",E);const e=d-c;e50&&u--,g(u)};n.addEventListener("mousedown",(e=>{i=!0,o=e.clientX,c=d,window.addEventListener("mousemove",h),window.addEventListener("mouseup",E)})),n.addEventListener("touchstart",(e=>{i=!0,o=e.touches[0].clientX,c=d,window.addEventListener("touchmove",h,{passive:!1}),window.addEventListener("touchend",E)}),{passive:!0}),r&&(r.onclick=()=>g(++u)),s&&(s.onclick=()=>g(--u)),t.addEventListener("click",(e=>{Math.abs(d-c)>10&&e.preventDefault()}),!0),window.addEventListener("resize",(()=>g(u))),v()}))}));