How to build a swipeable carousel with just CSS
Creem.io
Merchant of record payments for SaaS and digital products, handling checkout, subscriptions, taxes, invoices, and global payment operations.
I’m sure you’ve been there – you need a simple carousel: testimonials, product cards, a photo strip. So you reach for a library, ship 30kb of JavaScript, and spend an hour fighting its options. Right?
But the browser already knows how to scroll sideways and stop at a right position.
This feature is called CSS scroll snap, and it turns a plain overflow container into a swipeable carousel.
The fix
<ul class="carousel">
<li><img src="photo-1.jpg" alt="..." /></li>
<li><img src="photo-2.jpg" alt="..." /></li>
<li><img src="photo-3.jpg" alt="..." /></li>
</ul>
.carousel {
display: flex;
gap: 1rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.carousel > li {
flex: 0 0 80%;
scroll-snap-align: start;
}
And that’s the whole carousel. Swipe on touch, two-finger scroll on a trackpad, and every gesture lands exactly on a slide.
Let’s see what we have now.
- 1
- 2
- 3
- 4
- 5
Swipe on touch, two-finger scroll on a trackpad, or use the scrollbar. No JavaScript.
Where this comes up
- Testimonial sliders: the classic “what our customers say” row.
- Product galleries: image strips on shop pages, where every kb of JS hurts conversion.
- Card rows on mobile: pricing plans or features that sit in a grid on desktop but should swipe on small screens.
The values, explained
scroll-snap-type: x mandatory means the container always rests on a snap point. Swap mandatory for proximity if you only want snapping when the user stops near a slide:
.carousel {
scroll-snap-type: x proximity;
}
scroll-snap-align controls where each slide parks. Use center for a peek of the neighbors on both sides:
.carousel > li {
scroll-snap-align: center;
}
Two useful extras. scroll-snap-stop: always prevents a hard flick from skipping past slides:
.carousel > li {
scroll-snap-stop: always;
}
And scroll-padding keeps the first slide from hiding under any inset or edge fade:
.carousel {
scroll-padding-inline: 1rem;
}
One caveat worth testing in your own layout: Chrome makes a scroll container keyboard focusable only when it has no focusable children. The moment your slides contain a link or a button, which real testimonial and product cards do, that stops applying and arrow keys no longer reach the carousel. Add tabindex="0" to the container when your slides are interactive:
<ul class="carousel" tabindex="0">
Why this matters
- Zero JavaScript: nothing to load, nothing to break, nothing to maintain.
- Native gestures: touch, trackpad, and mouse wheel all work because it’s real scrolling, not a simulation of it.
- Semantic markup: screen readers see a plain list, not a pile of divs with ARIA bolted on.
- Broad support:
scroll-snap-typeandscroll-snap-alignwork in every modern browser.scroll-snap-stopis newer, so treat it as an enhancement rather than something you rely on.
Open a project with a carousel library in package.json. Find one carousel that’s mostly used on mobile, swap it for the six lines above, and see what you actually lose.
Happy coding!
Marko