DEV Community

Cover image for I Ripped Out a Carousel Library. CSS Replaced It.
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

I Ripped Out a Carousel Library. CSS Replaced It.

The bug ticket said "carousel feels broken on trackpad." It took me forty minutes to find the actual cause: my carousel library's scrollTo animation and the browser's own momentum scrolling were both trying to move the same element at the same time, and they disagreed.

Two requestAnimationFrame loops, fighting over one scrollLeft value, several times a second.

I fixed it by deleting the library.

The carousel everyone hand-rolls

Here's the pattern if you haven't built one recently: a flex row of cards, overflow-x: auto, and then JavaScript to make it feel like a carousel instead of a scrollbar with cards in it.

function scrollToCard(index) {
  const card = cards[index];
  track.scrollTo({ left: card.offsetLeft, behavior: "smooth" });
}

track.addEventListener("scroll", debounce(() => {
  const nearest = findNearestCard(track.scrollLeft);
  setActiveDot(nearest);
}, 100));

nextBtn.addEventListener("click", () => scrollToCard(activeIndex + 1));
Enter fullscreen mode Exit fullscreen mode

A scroll listener to track position. A debounce so it doesn't fire on every pixel. A findNearestCard function doing offsetLeft math. And the moment a user drags with a finger or a trackpad instead of clicking your arrows, you're racing the browser's native scroll physics with your own scrollTo calls — which is exactly the bug that ate my Tuesday.

None of that code is wrong, exactly. It's just solving a problem the browser already solved, badly enough that a plugin market exists around it.

What "snap" actually means to the browser

CSS has had scroll snapping since 2019 — Baseline, in every current browser, no vendor prefix, no polyfill. It doesn't animate a scroll position for you. It changes where the browser lets a scroll gesture rest.

Two properties do the whole job. One goes on the scroll container:

.track {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}
Enter fullscreen mode Exit fullscreen mode

One goes on each card:

.card {
  scroll-snap-align: center;
}
Enter fullscreen mode Exit fullscreen mode

That's it. Drag, flick, trackpad-swipe, or scrollbar-drag the track, and the browser's own scroll physics — the momentum, the deceleration curve, the bit that feels right on every input device because the browser's engineers tuned it for every input device — settles on whichever card's snap point is closest. No scrollTo, no debounce, no findNearestCard.

Try dragging it yourself before you take the rest of my word for it.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

The strictness question the demo above just answered

scroll-snap-type takes two words: an axis (x, y, or both) and a strictness (mandatory or proximity).

  • mandatory — the browser will always rest on a snap point when the scroll ends. Flick it a tiny bit, it still jumps to the nearest card.
  • proximity — the browser only snaps if you land close enough to a snap point already. Scroll to the middle of nowhere and it just... stays there.

Most carousels want mandatory. Long scrolling galleries where snapping should feel like a nudge, not a rule, want proximity — think a photo grid that snaps rows into place without ever fighting a scroll that's clearly headed somewhere else.

The part that makes people say "but what about—"

Two objections kill "just use CSS" conversations before they start, and both have answers.

"What if I want the next card to peek in at the edge?" That's scroll-padding on the container — it insets the snap area the same way padding insets content, so a card can snap to a position that isn't flush against the edge:

.track {
  scroll-snap-type: x mandatory;
  scroll-padding-inline: 24px;
}
Enter fullscreen mode Exit fullscreen mode

"What if a sticky header overlaps the top of my snapped section?" That's scroll-margin on the target, not the container — it pads the snap area around one specific element, so scroll-margin-top on a section keeps a sticky nav from covering its heading the moment it snaps into view. Same trick as scroll-padding, applied per-target instead of per-container.

Neither of those needs a resize listener recalculating offsets. They're declarative, and they respond to the viewport the same way the rest of your layout does.

Where a hand-rolled scroller still wins

I'm not going to pretend CSS snap subsumes every carousel. Autoplay — advancing on a timer without user input — is still your setInterval, because CSS has no concept of "and now do this without being touched." Infinite loop-around (card N wrapping back to card 1 seamlessly) is still awkward in pure CSS; you either fake it with cloned edge cards or accept a hard stop. And if you need to know the exact scroll-snap position programmatically — say, to sync a progress bar — you're back to a scroll listener, just a much simpler one than before, since you're reading position instead of fighting it.

The honest split: snapping, physics, and touch/trackpad feel are the browser's job now. Autoplay and looping are still yours.

The one-line version

If your carousel's JavaScript exists to make scrolling feel like snapping into place, delete it and write scroll-snap-type and scroll-snap-align instead — the browser has owned that job, well, since 2019. Keep your JavaScript for the parts CSS genuinely can't do: autoplay, looping, and reading position back out.

What's still sitting in your codebase doing a job the platform quietly took over? Scroll snap was mine — what's yours?

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.


Thanks for reading! Let's stay connected:

Top comments (0)