You can build a working carousel with no JavaScript in Chrome and Edge 135 and later. CSS Overflow Level 5 generates the previous and next buttons from ::scroll-button() and the dot indicators from ::scroll-marker, both sitting on top of ordinary scroll snap. Safari and Firefox support neither, so scroll snap has to carry the experience on its own everywhere else.
Chrome shipped the set in version 135, released to stable on 1 April 2025. Seventeen months on, no second engine has followed. That is the constraint everything below works around: the controls have to be optional, because for a large share of your users they will not exist.
What does the browser generate for you?
Four pseudo-elements do the work. Every one of them stays inert until you give it a content value other than none, which is the usual reason a first attempt renders nothing at all.
::scroll-button() takes a direction: up, down, left, right, the four logical equivalents (block-start, block-end, inline-start, inline-end), or * to match all of them. You get at most four per scroll container, one for each axis direction. The spec also defines next and prev, which no browser implements. Each button is a real button in the accessibility tree, and the browser switches it to :disabled by itself once there is nothing left to scroll that way. Pressing one moves the container by about a page, which the CSS Overflow 5 draft puts at “about 85% of the scrollport size”.
::scroll-marker is the dot. It appears on an element when two conditions hold together: that element’s ::scroll-marker has a non-none content value, and the element sits inside a scroll container whose scroll-marker-group is not none. The markers are then collected into a ::scroll-marker-group box, placed either before or after the container in both layout order and tab order. Each marker behaves like an anchor pointing at its own slide.
Three pseudo-classes report position. :target-current matches the marker for the slide in view. :target-before and :target-after match the ones on either side, which is what you need for a progress trail instead of a row of identical dots.
How do you build a CSS carousel without JavaScript?
Scroll snap goes first, because that layer is Baseline widely available and has worked across browsers since April 2022. The pseudo-elements go on top of it.
<ul class="carousel">
<li data-name="Slide 1"><h2>Page 1</h2></li>
<li data-name="Slide 2"><h2>Page 2</h2></li>
<li data-name="Slide 3"><h2>Page 3</h2></li>
</ul>
.carousel {
display: flex;
gap: 1rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
anchor-name: --carousel;
scroll-marker-group: after;
}
.carousel li {
flex: 0 0 100%;
scroll-snap-align: center;
list-style-type: none;
}
.carousel::scroll-button(left) {
content: "\25C4" / "Previous";
}
.carousel::scroll-button(right) {
content: "\25BA" / "Next";
}
.carousel::scroll-button(*) {
position: absolute;
position-anchor: --carousel;
border: 0;
background: none;
font-size: 2rem;
cursor: pointer;
}
.carousel::scroll-button(*):disabled {
opacity: 0.2;
cursor: unset;
}
.carousel::scroll-marker-group {
display: flex;
justify-content: center;
gap: 0.75rem;
}
.carousel li::scroll-marker {
content: attr(data-name);
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-radius: 50%;
overflow: hidden;
text-indent: 16px;
}
.carousel li::scroll-marker:target-current {
background: currentColor;
}
Two details there are easy to miss. The content: "\25C4" / "Previous" syntax supplies alternative text after the slash, which is what gives the button a sensible accessible name. And content: attr(data-name) pulls a label off each list item and then hides it visually with text-indent, because a bare content: "" leaves the marker with no accessible name at all.
Scroll snap is not decoration here. MDN’s carousel guide warns that without it the buttons and markers are unlikely to move cleanly between pages, and says the result will be substandard. Both controls scroll by a proportion of the box, not to a known slide boundary, so the snap points are what turn that percentage into a slide.
Sizing the slides is a separate problem, and container queries beat media queries for it, because a carousel item cares about the width of the strip it sits in and not the width of the viewport.
For a table of contents, where the markup already contains real links, scroll-target-group fits better:
nav ol {
scroll-target-group: auto;
}
nav a:target-current {
font-weight: 700;
}
Setting scroll-target-group: auto turns an existing element into a marker group. Any <a> inside it with a fragment identifier becomes a scroll marker. Per MDN, those links keep ordinary link semantics and pick up no special keyboard behaviour, which is what a contents list wants.
Which browsers support CSS carousels?
Chrome and Edge from 135, Opera from 120, Samsung Internet from 29. Firefox supports it in no version. Neither does Safari, on desktop or iOS. caniuse puts ::scroll-marker and ::scroll-button() at 71.55% of global usage. MDN marks ::scroll-marker, ::scroll-button() and scroll-marker-group as Limited availability, explicitly not Baseline.
The signals on when that changes are thin. Requests for a position went to both WebKit and Mozilla on 9 January 2025, each tagged with accessibility concerns. Neither vendor has stated one as of September 2026. Interop 2026, announced on 12 February 2026, lists twenty focus areas. Scroll Snap and Scroll-driven Animations are both on that list; scroll markers and scroll buttons are not.
If you want the other half of CSS scrolling work, our guide to scroll-driven animation timelines covers the part that did make Interop.
Have the accessibility semantics settled?
Not yet, and they changed under code that was already shipping. When markers first arrived, the group was exposed as a tablist and each marker as a tab. Sara Soueidan’s analysis of CSS carousel accessibility was published on 6 May 2025 and updated that August. Her objection is structural: if the markers are tabs, then “all the carousel examples in the gallery are supposed to be Tabs widgets”. ARIA expects a matching tabpanel to be rendered for the active tab, and most carousels have none. They also show several items at once, which is not what the tabs pattern describes.
That feedback reached the spec. scroll-marker-group now takes a mode alongside the placement keyword, with the grammar none | [ [ before | after ] || [ links | tabs ] ]. In links mode the group takes the navigation role, the markers take the link role, and every marker is a sequential tab stop. In tabs mode you get tablist and tab, a single focusgroup entry with arrow-key movement between markers, and inactive content hidden from the accessibility tree.
links is the default when the keyword is omitted. Chrome’s 154 beta notes of 2 September 2026 describe both modes, so as of September 2026 this sits in beta and not in stable. Existing CSS that says only scroll-marker-group: after picks up links semantics once it lands, which is nearer to what most carousels actually are.
One rough edge is still open. MDN notes that the multi-column variant, ::column::scroll-marker, has no way to supply an accessible name, and states that this produces a WCAG 4.1.2 Name, Role, Value violation. Skip that variant until it does.
How do you ship it safely today?
Build the scroll-snap carousel as the real thing, then add the pseudo-elements inside a feature query, so a browser without support gets a plain horizontal scroller instead of a broken layout:
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
}
@supports (scroll-marker-group: after) {
.carousel {
scroll-marker-group: after;
}
}
A property test is the reliable gate here. @supports has been widely available since September 2015, and testing the property sidesteps the question of whether selector() accepts a functional pseudo-element such as ::scroll-button(right). Leave the mode keyword out of that declaration until the modes reach stable, since links is the default anyway.
What I would actually do: use this on a marketing page, a product gallery, an image strip, anywhere the carousel is a convenience and every item stays reachable by scrolling without it. Do not use it where the dots are the only route to content, and do not reach for the ::column variant while the accessible-name gap is open. If you need matching behaviour in Safari and Firefox this quarter, keep the JavaScript carousel you have and look again when a second engine ships.
We build storefronts and marketing sites where this pattern turns up constantly, and a scroll-snap strip usually beats a scripted carousel on both weight and reliability. If you want the layout handled properly across breakpoints, that is part of our responsive design work.