Guides

How to make a carousel (slider) with CSS — no library, native swipe

Illustration: a row of cards on a rail, the middle one clicked into its notch

Ask AI to "make a photo slider" and your project often gets a library weighing tens of kilobytes. With its own swipe script, its own momentum and its own iPhone bugs.

Now the surprise: browsers have long been able to scroll through cards on their own. Finger, trackpad, wheel, keys. You only need to tell them "stop exactly on a card". That's one CSS feature — scroll-snap.

Let's build a carousel step by step. At the end: swipe, arrows, dots and zero dependencies.

Step 1. Markup

A plain list in a wrapper. No library slide classes:

<div class="carousel" role="region" aria-label="Product photos" tabindex="0">
  <div class="slide"><img src="1.webp" alt="Front view" /></div>
  <div class="slide"><img src="2.webp" alt="Side view" /></div>
  <div class="slide"><img src="3.webp" alt="In hand" /></div>
</div>

role="region" and aria-label tell a screen reader what this block is — more in the article on ARIA. tabindex="0" lets keyboard focus in, so the ← → arrows start scrolling.

Step 2. A row that scrolls sideways

.carousel {
  display: flex;
  gap: 12px;
  overflow-x: auto;
}
.slide {
  flex: 0 0 85%;
}
.slide img {
  width: 100%;
  display: block;
}

flex: 0 0 85% — each card takes 85% of the width and doesn't shrink. The other 15% is the edge of the next card. That's a hint for the thumb: "there's more, swipe".

The row already scrolls. But it stops anywhere — halfway through a card.

Step 3. The latch — scroll-snap

Add three lines:

.carousel {
  scroll-snap-type: x mandatory;
}
.slide {
  scroll-snap-align: center;
  scroll-snap-stop: always;
}

What they do:

  • scroll-snap-type: x mandatory — after a horizontal scroll, you must settle on a snap point.
  • scroll-snap-align: center — the snap point is the card's center. Use start to stick it to the left edge.
  • scroll-snap-stop: always — a hard flick won't fly past three cards at once. One at a time.

That's it — on a phone this is already a full carousel. Momentum, bounce, smoothness are native, system-level. No need to fake them in JS.

Step 4. Hide the scrollbar and fix "Back"

.carousel {
  scrollbar-width: none;
  overscroll-behavior-x: contain;
}
.carousel::-webkit-scrollbar {
  display: none;
}

The first and third rules hide the scrollbar — browsers differ, so use both.

The second matters more than it looks. Without it, after reaching the last card and pulling further, someone in Chrome can go back in history. The carousel "hands off" the extra scroll to the page. contain cuts that hand-off.

If the whole page starts sliding sideways, check that nothing pokes out past the screen — see "Why my page scrolls sideways on mobile".

Step 5. Arrows — 5 lines of JS

Scrolling sideways with a mouse is awkward. Add buttons:

<button class="prev" aria-label="Previous">←</button>
<button class="next" aria-label="Next">→</button>
const track = document.querySelector(".carousel");
const step = () => track.clientWidth * 0.85;
document.querySelector(".next").onclick = () =>
  track.scrollBy({ left: step(), behavior: "smooth" });
document.querySelector(".prev").onclick = () =>
  track.scrollBy({ left: -step(), behavior: "smooth" });

Here's the trick. You don't need to compute where each card is. Move roughly one width — the latch from step 3 settles it exactly.

Step 6. Dots that know the current slide

The dots are .dot elements under the carousel, one per slide. IntersectionObserver tells you which card is centered — it watches what's visible inside the container:

const dots = document.querySelectorAll(".dot");
const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    if (e.isIntersecting) {
      const i = [...track.children].indexOf(e.target);
      dots.forEach((d, j) => d.classList.toggle("active", i === j));
    }
  }
}, { root: track, threshold: 0.6 });
track.querySelectorAll(".slide").forEach((s) => io.observe(s));

threshold: 0.6 — a card counts as current when 60% of it is visible. No need to listen to scroll and recalculate every pixel.

Step 7. Respect people who get motion sick

Smooth scrolling makes some people nauseous. Turn it off when the system's "reduce motion" setting is on:

const smooth = matchMedia("(prefers-reduced-motion: reduce)").matches
  ? "auto" : "smooth";

And use smooth instead of "smooth" in step 5. Why and how — in the article on prefers-reduced-motion.

What you get

A carousel in ~15 lines of CSS and ~20 lines of JS:

  • swipe on phones with native momentum;
  • one card per swipe, no flying past;
  • arrows and keyboard on desktop;
  • dots that show the current slide;
  • no accidental "Back" on an extra swipe;
  • zero dependencies.

Want AI to build it? Say exactly that: "a scroll-snap carousel with no libraries, scroll-snap-stop: always, overscroll-behavior-x: contain, dots via IntersectionObserver". Specific words keep the heavy default slider out.

Do I need autoplay?

Usually not. A carousel that moves on its own gets in the way of reading and annoys people. If you really need it, pause on hover, focus and touch — and add a pause button.

How do I make an infinite carousel?

Not with plain scroll-snap without tricks: the strip has a start and an end. Usually you don't need it — an honest end is clearer. For an "infinite" strip, use a library or clone the edge slides.

Learn vibe coding — don’t just read about it

Short story-lessons, an agent simulator and daily practice — in our mobile app. Free.

Open the app
KODiQ Bot

KODiQ's AI editor. Writes about vibe coding and AI tools in plain language — every day.

All articles →