CSS Scroll Snap Carousel
A horizontal carousel using scroll-snap-type and scroll-snap-align.
A CSS scroll snap carousel is an overflowing flex row that the browser stops at defined points. scroll-snap-type: x mandatory on the container turns snapping on, scroll-snap-align: start on each slide says where it should come to rest, and the browser handles the momentum, the rubber banding, and the pointer physics. There is no slide index to track and no script to load.
The whole thing is native scrolling, which means it inherits every behavior a scroll container already has. Touch swipes work, trackpads work, Shift with the mouse wheel works, and arrow keys work once the container is focused. The variants below cover a rail where the next slide peeks in at the edge, and the difference between forcing every stop and merely suggesting one.
Scroll or swipe
← scroll or swipe →
HTML
<div class="carousel-wrap">
<div class="carousel">
<div class="carousel-slide">Slide 1 of 5</div>
<div class="carousel-slide">Slide 2 of 5</div>
<div class="carousel-slide">Slide 3 of 5</div>
<div class="carousel-slide">Slide 4 of 5</div>
<div class="carousel-slide">Slide 5 of 5</div>
</div>
<p class="carousel-hint">← scroll or swipe →</p>
</div>
CSS
/* The wrap sets how wide one slide is, since slides are 100% of it */
.carousel-wrap {
position: relative;
width: 100%;
max-width: 520px;
}
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
border-radius: 10px;
scrollbar-width: none;
}
.carousel::-webkit-scrollbar {
display: none;
}
/* One slide per view, and no gap, so every snap lands flush */
.carousel-slide {
flex: 0 0 100%;
height: 160px;
border-radius: 10px;
scroll-snap-align: start;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
font-weight: 700;
color: #fff;
}
.carousel-slide:nth-child(1) { background: #c084fc; }
.carousel-slide:nth-child(2) { background: #38bdf8; }
.carousel-slide:nth-child(3) { background: #ffd444; color: #0c0c0d; }
.carousel-slide:nth-child(4) { background: #57d9a3; }
.carousel-slide:nth-child(5) { background: #b8ff57; color: #0c0c0d; }
.carousel-hint {
text-align: center;
font-size: .75rem;
color: #88888f;
margin: .6rem 0 0;
letter-spacing: .02em;
}
Other ways to build it
Peek at the next slide
Dropping the slides to flex: 0 0 78% leaves part of the next one visible at the right edge, which tells a reader the rail scrolls without needing a hint underneath it. Two extra properties keep the snapping honest once slides are narrower than the frame. gap puts real space between them, and scroll-padding-inline moves the snap line in by the same amount as the container's own padding, so a slide comes to rest inside the padding rather than jammed against the edge of the box.
HTML
<div class="carousel-wrap carousel-wrap-fixed">
<div class="carousel carousel-peek">
<div class="carousel-slide">One</div>
<div class="carousel-slide">Two</div>
<div class="carousel-slide">Three</div>
<div class="carousel-slide">Four</div>
<div class="carousel-slide">Five</div>
</div>
</div>
CSS
/* the wrap has to carry the width, because percentage sized slides
measure themselves against the container */
.carousel-wrap {
width: 100%;
max-width: 520px;
}
.carousel-peek {
display: flex;
gap: .75rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-inline: .75rem;
/* moves the snap line in by the padding, so a slide rests inside
the box rather than flush against its edge */
scroll-padding-inline: .75rem;
}
/* under 100%, so the next slide shows at the trailing edge */
.carousel-peek .carousel-slide {
flex: 0 0 78%;
scroll-snap-align: start;
}
Proximity snapping, centered
Swapping mandatory for proximity changes the rail from something that insists on a stop to something that suggests one. Scroll a little and it settles on the nearest slide. Scroll past the halfway point of one and it lets you stop there. Pairing that with scroll-snap-align: center frames the active slide in the middle of the container rather than at the leading edge, which reads better when slides are narrower than the frame and there is content visible on both sides.
scroll or swipe, and stop anywhere
HTML
<div class="carousel-wrap carousel-wrap-fixed">
<div class="carousel carousel-prox">
<div class="carousel-slide">One</div>
<div class="carousel-slide">Two</div>
<div class="carousel-slide">Three</div>
<div class="carousel-slide">Four</div>
<div class="carousel-slide">Five</div>
</div>
<p class="carousel-hint">scroll or swipe, and stop anywhere</p>
</div>
CSS
.carousel-prox {
display: flex;
gap: .75rem;
overflow-x: auto;
/* suggests a stop instead of forcing one */
scroll-snap-type: x proximity;
}
.carousel-prox .carousel-slide {
flex: 0 0 60%;
/* the slide is framed in the middle rather than at the left edge */
scroll-snap-align: center;
}
@media (prefers-reduced-motion: reduce) {
.carousel-prox { scroll-behavior: auto; }
}
How it works
scroll-snap-type:x mandatory on the container forces snapping after each scroll gesture. scroll-snap-align:start on each slide defines where it snaps to. The container is just a flex row with overflow-x:auto. No JS, no next/prev buttons required, since the browser handles the snapping physics.
Two properties do the work and they live on different elements. scroll-snap-type goes on the scroll container and takes an axis plus a strictness: x mandatory here. scroll-snap-align goes on each child and takes start, center or end, describing which edge of the slide lines up with which edge of the container. Putting either one on the wrong element is the usual reason nothing snaps at all.
mandatory means the container must always come to rest on a snap point. The browser will move the scroll position to the nearest one even if the reader stopped halfway. proximity only snaps when the resting position is already close, which leaves a reader free to stop wherever they like. mandatory is right when every slide is the same size and fills the frame, and it becomes hostile when a slide is taller than the viewport, because it can pull content out of reach as the reader tries to read it.
Slides here are flex: 0 0 100%, meaning do not grow, do not shrink, and take the full width of the container. That last part is why the wrapper needs a width of its own: the slides measure themselves against it, so a wrapper with no max-width produces slides as wide as the page. flex-shrink: 0 is the piece people leave off, and without it flexbox compresses seven slides into one screen and there is nothing left to scroll.
The scrollbar is hidden with scrollbar-width: none and a ::-webkit-scrollbar { display: none } rule for browsers that predate it. That is a deliberate trade. Hiding it makes the rail look cleaner and removes the only visual cue that the content scrolls at all, which is why the demo carries a text hint underneath. On a desktop with no touchscreen, a carousel with no scrollbar and no arrows is easy to miss entirely.
scroll-padding on the container offsets where snap points land, which matters as soon as slides are narrower than the frame. A rail with 1rem of inline padding and slides aligned to start will snap the slide flush against the container edge, under the padding, unless scroll-padding-inline: 1rem moves the snap line inward to match.
CSS properties used
scroll-snap-type- Goes on the scroll container.
x mandatorysnaps on the horizontal axis and always comes to rest on a snap point.x proximityonly snaps when the stop is already near one. scroll-snap-align- Goes on each slide.
startlines the slide's left edge up with the container's left edge,centercenters it,endaligns the right edges. scroll-padding- Insets the snap positions from the container's edges, so a slide can snap to a point that accounts for padding or a sticky overlay.
overflow-xautois what makes the element a scroll container in the first place. Without it there is nothing to snap.flex0 0 100%fixes each slide at the container width and stops flexbox shrinking them all to fit.scrollbar-widthnonehides the native scrollbar. Worth pairing with a visible hint, since it also removes the only clue that the rail scrolls.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
scroll-snap | 69 | 68 | 11 | 79 |
flexbox | 21 | 28 | 6.1 | 12 |
scroll-behavior | 61 | 36 | 15.4 | 79 |
overscroll-behavior | 65 | 59 | 16 | 79 |
scrollbar-width | 121 | 64 | 26.2 | 121 |
The scroll snap figures are for the current specification. An older and incompatible draft using scroll-snap-points-x shipped in Firefox and Edge years earlier, so a snippet copied from an old article may use syntax that no current browser understands. scrollbar-width is by far the newest property in the table, which is why the standard pattern still pairs it with the non-standard ::-webkit-scrollbar rule. Where neither applies the scrollbar simply shows, which costs nothing but a few pixels.
Accessibility notes
A scroll container is focusable in most browsers once it actually overflows, and from there the arrow keys, Page Up, Page Down, Home and End all move it. That is more keyboard support than a scripted carousel usually ships with. It only holds if the container is the element that scrolls, so wrapping slides in an inner div that scrolls instead moves the focusable element somewhere the reader cannot see.
Hiding the scrollbar removes the affordance along with the chrome. On a desktop without a touchscreen there is then no indication the rail moves. A visible hint, paired arrow links, or leaving part of the next slide showing all solve it, and the peek variant below is the least intrusive of the three.
scroll-snap-type: mandatory can trap content. If a slide is taller than the viewport, the browser keeps pulling the scroll position back to the snap point while the reader is trying to read the bottom of it. Use proximity whenever slide height is not fixed, and reserve mandatory for a rail of equally sized panels.
scroll-behavior: smooth animates programmatic jumps, including the ones a keyboard makes. It is motion, and it should be dropped inside a prefers-reduced-motion block so those readers get an instant jump instead.
What you can build with it
- Image galleries. One photograph per view with a mandatory snap, which is the case the whole feature was designed around.
- Onboarding steps. A short sequence of full width panels a reader swipes through, with the scroll position acting as the step counter.
- Testimonial rotators. Quotes at one per view. Snapping keeps each one framed instead of leaving two half quotes on screen.
- Product card rails. Narrower slides with the next one peeking in. The scroll shelf covers that layout in more detail.
- Mobile tab strips. A row of category chips that scrolls sideways and snaps each chip to the leading edge.
Mistakes worth avoiding
- Putting
scroll-snap-alignon the container orscroll-snap-typeon the slides. Nothing snaps, and there is no error to tell you why. - Leaving
overflow-x: autooff. Scroll snap has no effect on an element that is not a scroll container. - Using
flex: 0 0 100%without giving the wrapper a width. The slides size themselves against the container, so they end up as wide as the page. - Forgetting
flex-shrink: 0. Flexbox squeezes every slide into the visible width, the row stops overflowing, and the carousel has nothing to scroll. - Applying
mandatoryto slides taller than the screen. The browser keeps snapping back while the reader is still reading, which makes the lower part of a slide effectively unreachable.
Frequently asked questions
How do you build a carousel with CSS only?
overflow-x: auto, add scroll-snap-type: x mandatory, size each slide with flex: 0 0 100%, and give each one scroll-snap-align: start. The browser handles the momentum and the snapping, so no script is needed for scrolling or swiping.Why is my scroll snap not working?
overflow-x: auto or scroll, scroll-snap-type has to be on that same container, and scroll-snap-align has to be on the children. Snap also does nothing when the content does not actually overflow, which is easy to miss on a wide screen.What is the difference between mandatory and proximity?
mandatory always comes to rest on a snap point, moving the scroll position there even when the reader stopped in between. proximity only does it when the resting position is already close to one. Use mandatory for equally sized slides that fill the frame and proximity whenever slide heights vary.Can you add next and previous buttons without JavaScript?
id and making the buttons anchors pointing at it. A fragment navigation scrolls the container, and with scroll-behavior: smooth it animates. The limitation is that the links cannot know which slide is currently showing, so they cannot be disabled at either end.Does drag to scroll work on desktop?
JavaScript needed for drag
Click-and-drag scrolling on desktop is a browser limitation. overflow-x: auto containers don't respond to mouse drag without JavaScript pointer event handlers. Scrollwheel on desktop and touch swipe on mobile work natively with pure CSS.