The carousel is the component every project builds from scratch one more time. Sometimes a library comes along with thirty kilobytes, sometimes it is two hundred lines of your own that measure widths, keep an index and recompute on every reflow. Meanwhile the browser has long been able to do the hard part itself: swiping, inertia, snapping, direction in the writing flow. That has been in every major browser since 2019 and is called scroll snap. Since Chrome 135 the part that used to justify your own code has joined it, namely the dots under the row and the arrows beside it, generated by the browser and operable with the keyboard. This guide builds the carousel in two layers: underneath the part that already works everywhere, on top the part Chromium adds and others simply leave out, without anything breaking.
- The foundation is a scroll container, not a widget. overflow-x set to auto, scroll-snap-type on the row, scroll-snap-align on the children: that finishes the swiping, inertia and snapping included.
- scroll-snap-stop with always stops a firm swipe from flying past three items. Without that line a carousel feels uncontrolled on a phone.
- scroll-marker-group and scroll-button produce dots and arrows with no markup. The browser gives them button semantics, arrow keys and a state that keeps itself in sync.
- This second layer so far exists only in Chromium. Because it replaces nothing and merely adds, it needs no fallback, only an order: first the version that carries everywhere, then the addition.
Why does the carousel keep being rebuilt?
A carousel consists of four things: a row that can be moved sideways, a snap onto the items, an indicator of where you currently are, and two buttons for forward and back. Only the third and the fourth are real work. Even so, most projects end up with a package that brings all four along, and therefore rebuilds the swiping itself as well.
That is where the real effort goes, and it goes to a place nobody wants to see it. Rebuilding motion in JavaScript means taking on inertia, deceleration, what happens when a finger lifts mid-movement, the direction under right-to-left writing, the behaviour under zoom and a trackpad that accelerates differently from a thumb. Each of those is small on its own and together they are the reason home-made carousels always feel a touch wrong.
The browser already has all of it, and not as an extra but as its main job. An element with too much content is a scroll container, and a scroll container scrolls correctly. The only missing instruction is where it should come to rest. That instruction is exactly what scroll snap gives, and with it the hard part is done before it started.
A carousel is not a widget. It is a row that scrolls, with two instructions added.
What turns a row into a scroll container?
Three declarations are enough. The row gets overflow-x set to auto so that it scrolls at all. It gets scroll-snap-type with an axis and a strictness, usually x mandatory, so the browser heads for a point once a finger lifts. And the children get scroll-snap-align, which decides which of their edges lines up with the container. With center the current item sits in the middle, with start at the left edge.
Two further lines decide whether the result feels good. scroll-snap-stop set to always makes sure every item is actually visited and a firm swipe does not fly past three of them. And scroll-padding keeps a strip free at the container edges so the item being approached does not stick flush to the edge. Both are missing from most examples and both are noticed immediately when they are.
The width of the items belongs to the same decision. A fixed width turns the carousel into a row of equally sized cards, a value like min(28rem, 85%) makes sure a sliver of the next item stays visible on a phone. That visible sliver is not decoration, it is the only place where the interface reveals on its own that there is more. Leave it out and you need the arrows from the next section twice as badly.
<section className=" px-6"> <div className="mx-auto flex flex-col "> <p>Eyebrow</p> <h1>Headline</h1> <p>Two sentences</p> <div className="flex "> <a>Book a call</a> <a>Work</a> </div> </div></section>
Point at a highlighted class to see where it applies in the preview.
Three declarations on the row, two on the children. Nobody steers the swiping itself.
.carousel { display: flex; gap: 1rem; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: 1.5rem;} .carousel > li { flex: 0 0 min(28rem, 85%); scroll-snap-align: center; scroll-snap-stop: always;}
Where do dots and arrows come from without markup?
The dots under a carousel have so far been handwork: one element per image, one click listener per element, a class reflecting the current position and a computation keeping that position in sync while scrolling. Those four things are exactly what the browser now takes over. The row gets scroll-marker-group set to before or after, and that creates an area the markers live in. Every child can produce a ::scroll-marker as soon as it gets a content value, including the empty one, meaning content with two quotation marks.
The current position keeps itself in sync. The pseudo-class :target-current always hits precisely the marker whose item is currently in view, and does so without a single line computing anything while scrolling. A click on a marker jumps to the matching item, again with no help. What you still write there is pure styling: size, shape, spacing and the difference between active and inactive.
The arrows follow the same pattern. ::scroll-button takes a direction as its argument, so left, right, up and down or their equivalents in the writing flow, and produces a real button once it has a content value. Pressing it scrolls on by roughly one container width, which with snapping set means to the next item. And as soon as nothing follows in a direction, the browser disables the button on its own, queryable with :disabled. That is the line people most often forget in solutions of their own.
.carousel { scroll-marker-group: after; } .carousel::scroll-marker-group { display: flex; justify-content: center; gap: 0.5rem;} .carousel > li::scroll-marker { content: ''; inline-size: 0.5rem; block-size: 0.5rem; border: 1px solid currentColor; border-radius: 50%;} .carousel > li::scroll-marker:target-current { background: currentColor;} .carousel::scroll-button(left) { content: '\2039'; }.carousel::scroll-button(right) { content: '\203A'; } .carousel::scroll-button(*):disabled { opacity: 0.3; cursor: default;}
The state is not maintained, it is queried. That is the whole difference.
Great heroes remove decisions.
Three arrangements that work. Which one fits is decided by the imagery, not by taste.
What does the keyboard get out of it?
More than most home-made carousels deliver. As soon as a marker group exists, the row takes on the semantics of a tab list: the group is one tab stop, and inside it the arrow keys move from item to item. Markers and buttons are not styled divs but buttons with everything that entails, and the browser assigns the accessible names itself. The glyph in content therefore needs no label beside it.
Where before and after sit is not a matter of taste in this. The value decides not only the position in the layout but also the place in the tab order. A group sitting below the row but reached before it produces exactly the jump that feels like disorder on somebody else's site. So set the value to match what people see.
That leaves the scroll container itself. Chromium has given a scroll container a tab stop since version 132, but only when nothing else inside it is focusable. That cannot be built on, because the other browsers handle it differently. The reliable rule is therefore simple: if the items contain links or buttons anyway, the keyboard reaches them the normal way and scrolling follows the focus. If they hold only images or text, give the row itself a tabindex of zero and a name, so that it is reachable and announced at all.
Which part runs where today?
The two layers are at different stages. Snapping is old and present everywhere: scroll-snap-type and scroll-snap-align have been in Chrome since version 69, in Safari since version 11 and in Firefox since version 68, with scroll-snap-stop joining in Firefox 103. That layer needs no query, it is everyday material.
The markers and the buttons are new. ::scroll-marker, ::scroll-marker-group, ::scroll-button and :target-current arrived together in Chrome 135 and therefore in Edge as well. In Safari and in Firefox they are still missing. That matters more for the decision than it sounds: this is not about a fallback, it is about an order. What a browser does not know, it does not generate, and what remains is the lower layer, which was complete anyway.
One query is needed all the same, at exactly one spot, the one where you take something away. Hiding the scrollbar belongs in a block only the browser reads that also creates markers, and for that @supports with selector and the name of the pseudo-element is enough. If you want markers where they do not yet exist, Chrome 140 brought a second route: scroll-target-group set to auto turns an existing element full of fragment links into a marker bar and colors the current one with :target-current. The links inside it are real links and work in every browser, only the state keeping itself in sync remains a Chromium matter for now.
/* Everywhere: the scrollbar stays as the indicator */.carousel { scrollbar-width: auto; } @supports selector(::scroll-marker) { .carousel { scrollbar-width: none; }}
- Hiding the scrollbar across the board and leaving the controls to the markers. In Safari and Firefox what remains is a row with no hint at all that it continues.
- Rebuilding the markers in JavaScript where they are missing. That puts the library back in the bundle, only now in two versions instead of one.
- Tying the carousel to the markers being there, for instance by making items reachable only through a marker.
- Skipping @supports because nothing breaks anyway. That holds for everything you add and not for what you take away.
- Building the row so that scrolling alone makes it fully operable, visible bar and a sliver of the next item included.
- Laying markers and buttons on top without them replacing anything. Where they exist they are more convenient, where they do not, only convenience is missing.
- Writing exactly one query, and wrapping it around hiding the scrollbar.
- Actually looking at the version without markers once, ideally in Firefox. That is the state a share of your visitors sees.
Where does CSS stop?
Four things stay outside, and they are easy to remember because they all have the same thing in common: in all four, something moves without anybody having moved it.
- Advancing on its own. A carousel jumping on every five seconds needs a timer and therefore code. If it has to be: pause on hover and on focus, and check prefers-reduced-motion before the timer starts at all.
- The endless loop. Starting at the first item again after the last one is not something CSS provides for. The buttons switch off at the edge instead, and that is no shortcoming: a disabled button says this is the end, a jump back to the start says nothing.
- Loading more. Items that are only loaded once they appear need an observer. For images, though, loading set to lazy is often enough, and then this point too comes without code of your own.
- The smooth jump. scroll-behavior set to smooth makes a jump travel rather than jump. That declaration belongs inside a prefers-reduced-motion query, because a row sliding across is exactly the kind of motion some people want to avoid.
That leaves the question every carousel ends with and that has nothing to do with CSS: does the content belong behind a movement at all? Whatever sits in second place is rarely seen, and that holds regardless of how well the snapping works. For eight product shots of equal rank that is fine, for two statements that both matter it is not.
If the content does belong there, the balance of this approach is clear: what used to be a dependency is now twenty lines of CSS nobody has to keep updated. And the part still missing today is missing in the most pleasant way there is, namely without a trace.
Everything that runs by itself needs code. Everything a person moves does not.




