Get Your Audit

EN

RU

Soon

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.

Cards fit their content every card is a different width width: max-content
  • metric LCP
  • metric Cumulative Layout Shift
  • metric TTFB
  • metric Interaction to Next Paint
  • metric Speed Index
  • metric FCP
Uniform media cards fixed footprint, with arrows width: 15rem

Recent work

  • 16:9
    Project 01 Landing page
  • 16:9
    Project 02 E-commerce
  • 16:9
    Project 03 Web app
  • 16:9
    Project 04 Portfolio
  • 16:9
    Project 05 Blog
Dark surface inverted dots & arrows .bg-dark

On dark

  • step Audit
  • step Strip the bloat
  • step Pre-render
  • step Ship to the edge
  • step Measure

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.

HTML
<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.

CSS
/*! 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.
JavaScript
/*! 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()}))}));