
CSS Scroll Snap: Creating Smooth, Swipeable Carousels
Carousels have a reputation problem. Many of them are heavy JavaScript widgets that hijack touch events, fight the browser's own scrolling physics, and break in subtle ways on devices their authors never tested. Yet the underlying pattern, a row of items you can swipe through that settles neatly on each one, is genuinely useful for product galleries, testimonials, image sets, and onboarding screens.
CSS Scroll Snap lets you build that pattern on top of native scrolling. The browser handles touch, trackpad momentum, mouse wheels, and keyboard scrolling exactly as users expect, and your CSS simply tells it where to come to rest. In this guide we'll build a full-width slider and a multi-card carousel, then cover padding, alignment, stop behavior, navigation buttons, and the newer CSS carousel features that are starting to appear in browsers.
How Scroll Snap Works
Scroll snapping has two sides:
- The scroll container declares that it snaps, and along which axis. That's
scroll-snap-type. - The children declare where they want to be caught. That's
scroll-snap-align.
When the user finishes a scroll gesture, the browser looks for the nearest snap position and settles there with a smooth animation. Nothing about the scrolling itself is replaced; it's the same scroll you'd get from overflow: auto, just with resting points.
The minimal example looks like this:
<div class="slider">
<div class="slide">1</div>
<div class="slide">2</div>
<div class="slide">3</div>
</div>
.slider {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.slide {
flex: 0 0 100%;
scroll-snap-align: start;
}
That's a working swipeable slider. Each slide takes the full width of the container, and each swipe settles on the start of a slide. Scroll Snap is supported in all current major browsers.
Understanding scroll-snap-type
The property takes an axis and a strictness:
scroll-snap-type: x mandatory;
scroll-snap-type: y proximity;
scroll-snap-type: both mandatory;
- Axis :
x,y,both, or the logicalinlineandblock. mandatory: The container always rests on a snap point after scrolling. This is the right choice for slide-by-slide galleries.proximity: The container snaps only if scrolling ends close to a snap point; otherwise it stays where it is. This works better for long content or items taller than the viewport, where forcing a snap could make some content unreachable.
A rule of thumb: use mandatory when every item fits comfortably within the container, and proximity when items vary in size or might be larger than the container.
Understanding scroll-snap-align
This property sits on the children and chooses which edge of the child lines up with the container's snapport:
start: the child's leading edge aligns with the container's leading edge.center: the child is centered in the container.end: the child's trailing edge aligns with the container's trailing edge.none: the child isn't a snap target.
For full-width slides, start and center produce the same result. For cards narrower than the container, center gives a "focused card with peeking neighbors" look, while start gives a left-aligned list.
Building a Full-Width Image Slider
Let's make a polished version of the minimal example.
<section class="gallery" aria-label="Product photos">
<div class="gallery__track" tabindex="0">
<figure class="gallery__slide">
<img src="/images/bag-front.jpg" alt="Leather bag, front view" />
</figure>
<figure class="gallery__slide">
<img
src="/images/bag-side.jpg"
alt="Leather bag, side view showing strap"
/>
</figure>
<figure class="gallery__slide">
<img src="/images/bag-inside.jpg" alt="Interior with laptop sleeve" />
</figure>
</div>
</section>
.gallery__track {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
overscroll-behavior-x: contain;
scrollbar-width: none;
border-radius: 16px;
}
.gallery__slide {
flex: 0 0 100%;
margin: 0;
scroll-snap-align: center;
scroll-snap-stop: always;
}
.gallery__slide img {
display: block;
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
.gallery__track:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 4px;
}
A few details are doing important work here:
tabindex="0"on the track makes the scroll container keyboard-focusable, so keyboard users can focus it and use the arrow keys to scroll. Some browsers make scrollable containers focusable automatically, but adding it explicitly is more reliable. Give the container a visible focus style.overscroll-behavior-x: containstops a swipe that reaches the end of the carousel from triggering the browser's back/forward navigation gesture or scrolling a parent element.scroll-snap-stop: alwaysprevents a fast flick from skipping past several slides. The scroll must stop at each slide in turn, which is exactly what you want for a gallery where every image matters.scrollbar-width: nonehides the scrollbar. This is reasonable for a slider with its own navigation and a clear visual affordance, but make sure there's another cue that the content scrolls.
Building a Multi-Card Carousel with Peeking Items
A product or testimonial row usually shows several cards at once, with the next card partially visible to hint that there's more.
<div class="cards" tabindex="0" aria-label="Customer testimonials">
<article class="card">...</article>
<article class="card">...</article>
<article class="card">...</article>
<article class="card">...</article>
<article class="card">...</article>
</div>
.cards {
--gap: 1rem;
--pad: 1.5rem;
display: grid;
grid-auto-flow: column;
grid-auto-columns: min(80%, 20rem);
gap: var(--gap);
padding-inline: var(--pad);
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-padding-inline: var(--pad);
overscroll-behavior-x: contain;
}
.card {
scroll-snap-align: start;
padding: 1.5rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
}
Grid with grid-auto-flow: column is a clean way to lay items in a single row with consistent widths. min(80%, 20rem) means each card is 20rem wide, but never more than 80% of the container. On a phone, that leaves a slice of the next card visible.
Why scroll-padding matters
Without scroll-padding-inline, the snap position is the very edge of the container. Because we added padding-inline to the track for breathing room, cards would snap flush against the edge and hide that padding. scroll-padding insets the snap area, so cards come to rest 1.5rem in from the edge, aligned with the padding.
There's a complementary property on the children, scroll-margin, which adjusts the snap position of an individual item. You'll use it less often in carousels, but it's handy for a first item that needs a different offset.
A note about trailing padding
In some older browsers, the padding at the end of a horizontal scroll container wasn't included in the scrollable area, so the last card sat flush against the edge. Current browsers handle it correctly, but if you see this, a simple fix is a pseudo-element spacer:
.cards::after {
content: "";
width: 1px;
}
Centered Carousels
For a "focused card" look, center-align the cards and add padding that lets the first and last card reach the center:
.cards--centered {
--card: min(70%, 22rem);
grid-auto-columns: var(--card);
padding-inline: calc((100% - var(--card)) / 2);
scroll-padding-inline: 0;
}
.cards--centered .card {
scroll-snap-align: center;
}
The padding equals half the leftover space, so even the first card can scroll into the middle of the container.
Adding Navigation Buttons
Swiping is natural on touch screens, but desktop mouse users need buttons. Scroll Snap works well with a tiny script, because you only need to scroll by roughly one card and let snapping handle the precise alignment:
<div class="carousel">
<button class="carousel__btn" data-dir="-1" aria-label="Previous">
←
</button>
<div class="cards" tabindex="0">...</div>
<button class="carousel__btn" data-dir="1" aria-label="Next">→</button>
</div>
document.querySelectorAll(".carousel").forEach((carousel) => {
const track = carousel.querySelector(".cards");
carousel.querySelectorAll(".carousel__btn").forEach((btn) => {
btn.addEventListener("click", () => {
const card = track.firstElementChild;
const step = card.getBoundingClientRect().width;
track.scrollBy({
left: step * Number(btn.dataset.dir),
behavior: "smooth",
});
});
});
});
.carousel {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.5rem;
}
.carousel__btn {
width: 2.75rem;
aspect-ratio: 1;
border: 1px solid #cbd5e1;
border-radius: 50%;
background: #fff;
font-size: 1.25rem;
cursor: pointer;
}
@media (hover: none) {
.carousel__btn {
display: none;
}
}
The hover: none media query hides the buttons on touch-first devices where swiping is the expected interaction. Leave them visible if you want to support users who struggle with swipe gestures.
Native CSS Carousels: ::scroll-button and ::scroll-marker
Chromium-based browsers have begun shipping pseudo-elements that generate carousel controls directly from CSS:
::scroll-button()creates previous and next buttons on a scroll container. The browser handles clicking, disabling at the ends, and accessibility.::scroll-markerand::scroll-marker-groupcreate a set of dot indicators, one per item, which the browser keeps in sync with scroll position.
@supports selector(::scroll-button(*)) {
.cards {
anchor-name: --cards;
}
.cards::scroll-button(left) {
content: "\2190" / "Previous";
}
.cards::scroll-button(right) {
content: "\2192" / "Next";
}
.cards::scroll-button(*) {
position: absolute;
position-anchor: --cards;
top: anchor(center);
width: 2.5rem;
aspect-ratio: 1;
border-radius: 50%;
border: 0;
background: #fff;
}
.cards::scroll-button(left) {
left: anchor(left);
}
.cards::scroll-button(right) {
right: anchor(right);
}
}
At the time of writing, these features are only available in Chromium-based browsers and the specification is still evolving. Treat them as an experiment and wrap them in @supports, keeping the JavaScript buttons as the default for other browsers. The example uses anchor positioning to place the buttons over the carousel, which is itself a newer feature, so check caniuse for both before relying on this in production.
Reacting to the Snapped Item
A common requirement is highlighting the active slide or updating a counter. For a long time that meant an IntersectionObserver, which still works everywhere:
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
entry.target.classList.toggle("is-active", entry.isIntersecting);
});
},
{ root: document.querySelector(".cards"), threshold: 0.6 },
);
document
.querySelectorAll(".cards .card")
.forEach((card) => observer.observe(card));
Newer browsers also fire scrollsnapchange and scrollsnapchanging events on the scroll container, which tell you exactly which element became the snap target. Support is currently limited to Chromium-based browsers, so feature-detect before using them.
Accessibility and UX Best Practices
- Don't autoplay. Auto-advancing carousels move content away from people who are still reading it, and they're a common accessibility complaint. If you must autoplay, include a clearly visible pause button and stop on hover and focus.
- Label the region. Use
aria-labelon the container, such as "Customer testimonials", so screen reader users know what they're in. - Keep items reachable by keyboard. If cards contain links, tabbing through them will scroll each into view. Test that the snap doesn't fight the focus scroll.
- Show there's more. A partially visible next card, a fade mask, or dot indicators all tell users the row scrolls.
- Use
proximityfor tall content. If a slide might be taller than the viewport on small screens,mandatorysnapping on the vertical axis can make parts of it unreachable. - Respect reduced motion. Smooth scrolling from buttons should be instant for users who prefer less motion:
@media (prefers-reduced-motion: reduce) {
.cards {
scroll-behavior: auto;
}
}
And in the script, check the preference before passing behavior: "smooth":
const smooth = !matchMedia("(prefers-reduced-motion: reduce)").matches;
track.scrollBy({ left: step, behavior: smooth ? "smooth" : "auto" });
Common Pitfalls
- Snap isn't happening : Make sure
scroll-snap-typeis on the element that actually scrolls, the one withoverflow-x: auto, not on a wrapper around it. - Items shrink instead of overflowing : Flex items shrink by default. Use
flex: 0 0 <width>or the grid approach so they keep their size. - Snapping jumps after resizing : Browsers re-snap after layout changes, which is usually what you want. If content loads late (like images without dimensions), give images an
aspect-ratioor explicit size so the layout doesn't shift under the user. - Fast flicks skip items : Add
scroll-snap-stop: alwaysto the items if every item should be seen. - Trackpad feels "sticky" :
mandatorysnapping with small items on a trackpad can feel restrictive. Tryproximity, or make items larger.
Conclusion
CSS Scroll Snap turns a plain overflow container into a carousel that feels native on every device, because it is native. Two properties, scroll-snap-type on the container and scroll-snap-align on the items, do most of the work. scroll-padding, scroll-snap-stop, and overscroll-behavior refine the experience, and a few lines of JavaScript add desktop navigation buttons.
Keep an eye on ::scroll-button and ::scroll-marker. Once they're available beyond Chromium, entire carousels, with controls and indicators, will be possible without any script at all. Until then, Scroll Snap with a small progressive enhancement is the lightest, most robust carousel you can build.


