Up front: everything here was tested in Chromium 141 (and retested in Chromium 153 with the same results), and the accessibility tree was read from the browser's accessibility API. How a screen reader actually announces it is best checked by turning one on yourself. If I've gotten something wrong, corrections welcome.

October 2026 update: Chrome 154 adds links (default) and tabs modes to scroll-marker-group, which change the markers' focus order and ARIA semantics. In tabs mode, only the selected marker is focusable, the arrow keys switch between markers, and unselected slides are excluded from the accessibility tree, which addresses several of the problems in this post. In Chromium 153, scroll-marker-group: after tabs is already accepted as syntax, but behaves exactly like the default, so the conclusions below describe Chromium up to 153. I'll retest and update this post once I can get Chrome 154.

Intro

The carousel is probably the component that goes wrong for accessibility most often: unnamed dots, buttons that are just an arrow glyph, focus that wanders off, slides that rotate on their own forever.

CSS now has ::scroll-marker and ::scroll-button(), which build dots and previous/next buttons with no JavaScript. It's one of the items in my CSS & HTML cheat sheet I was most curious about: if the browser builds it natively, is it more accessible?

As it happens, I built an accessible carousel, <au-carousel>, for Accesserty UI Kit. So this time I put the two side by side with exactly the same content.

First, here's what your browser supports:

Your browser's support

  • ::scroll-marker / ::scroll-button()Detecting (requires JavaScript)

First, turn the content into a sideways-scrolling list that snaps into place. This part is scroll-snap, which is well established:

.carousel {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}
.carousel > li {
  flex: 0 0 100%;
  scroll-snap-align: start;
}

Then add the new pieces:

/* Dots: one per li, grouped after the carousel */
.carousel {
  scroll-marker-group: after;
}
.carousel > li::scroll-marker {
  content: "" / attr(data-title); /* a dot on screen; after the slash is the name for screen readers */
}
.carousel > li::scroll-marker:target-current {
  /* the current slide's dot */
}

/* Previous/next buttons */
.carousel::scroll-button(inline-start) {
  content: "โ€น" / attr(data-prev);
}
.carousel::scroll-button(inline-end) {
  content: "โ€บ" / attr(data-next);
}
<ul class="carousel" aria-label="Travel plans" data-prev="Previous slide" data-next="Next slide">
  <li data-title="Kyoto">โ€ฆ</li>
  <li data-title="Osaka">โ€ฆ</li>
</ul>

What follows the slash in content is the alternative text, the name a screen reader announces. In my tests attr() works inside the alternative text, so names can come from data-* attributes in the HTML, which makes localization much easier.

Here it is in action:

A pitfall I hit while building the demo: ::scroll-marker-group is a full-width row, so if you position the previous/next buttons at the two ends of that same row, the row covers them and mouse clicks don't reach them (the keyboard still works). Giving the buttons a z-index fixes it:

.carousel::scroll-button(*) {
  position: absolute;
  z-index: 1;
}

2. What it becomes in the accessibility tree

I read the accessibility tree of the demo above:

On screenRole in the treeNameState
Previous/nextbuttonAlternative text ("Previous slide")disabled at either end
The group of dotstablistNo nameโ€”
Each dottabAlternative text ("Kyoto")selected on the current slide
Each slideStays a listitemโ€”โ€”

Two things stand out:

  • The dots use tab semantics: they become tabs, but the slides don't become matching tabpanels; they stay plain list items. A screen reader user who hears "tab" expects it to switch a panel.
  • The tablist has no name: put two carousels on a page and you get two unnamed tab lists that can't be told apart.

3. Keyboard testing

Starting from a button before the carousel and pressing Tab:

  1. The dots take a single tab stop, landing on the current slide's dot.
  2. Left and right arrow keys move between dots; pressing left on the first wraps around to the last.
  3. The next Tab goes to the "next" button. "Previous" starts out disabled, so it's skipped.
  4. Then come the buttons inside the slides, all of them, not just the current slide's.

One surprise in the order: with the dots after the carousel (scroll-marker-group: after) and with them before (before), the Tab order was "dots โ†’ next โ†’ content" either way, which doesn't match their position on screen.

The biggest problem is at the last slide:

  • Keep pressing "next" until the last slide and the button becomes disabled, with focus still sitting on the now-unusable button, its focus ring fading too.
  • Press Tab now and focus jumps to the button in the first slide, and the carousel scrolls all the way back to the start.

In other words, once a keyboard user reaches the last slide, the next step takes them back to the beginning.

The native version also announces nothing. Press "next", the slide changes, and the screen reader isn't told which slide is now showing.

4. How I built it in the UI Kit

Here's the same content using <au-carousel> from Accesserty UI Kit:

It looks much the same, but these are deliberate choices:

  • The carousel has a name and a role description: the outer element is role="group", named "Travel plans", with an aria-roledescription of "carousel", so a screen reader knows what the whole block is.
  • The dots are real buttons and say where they are: their names are like "Kyoto, 1 of 4", and the current one gets aria-current, with no tab semantics.
  • The group of dots has a name and instructions too: "Choose a slide", plus "Use the arrow keys to move between slides" through aria-describedby.
  • The dots come before the content: you learn how many slides there are first, then read them; previous/next come after the content.
  • Tab from the dots goes straight into the current slide: not the first slide, and not whichever is leftmost on screen.
  • The end buttons use aria-disabled, not disabled: they stay focusable, so focus isn't lost and you're not thrown back to the start.
  • Slide changes are announced in a live region: for example "Nara, slide 3 of 4". It stays quiet while focus is on a dot, since the dot's name has just been read.
  • No autoplay: which sidesteps WCAG 2.2.2 Pause, Stop, Hide altogether.
  • Other details: no smooth scrolling under prefers-reduced-motion, styles for forced colors mode, and dots and buttons with hit areas of at least 24px.

Its accessibility tree in my tests: the outer element is a named group, the dots sit in a group named "Choose a slide", and each dot is a button. At the last slide, focus stays on "next" and the live region announces the last slide's name.

5. Side by side

AspectNative CSS<au-carousel>
Needs JavaScriptNoYes
BrowsersChromium only, for nowNot limited to Chromium (a regular web component)
Name for the carouselAdd aria-label to the container yourselfYes, plus a role description
Dot semanticstablist/tabPlain buttons + aria-current
Name for the dot groupNoneYes, plus instructions
Dot namesAlternative textTitle + position ("1 of 4")
Arrow keysYes, wrapping at the endsYes, stopping at the ends, plus Home/End
Entering the contentDOM order, from the first slideStraight to the current slide
End buttonsdisabled, focus is lostaria-disabled, focus stays
Announcing slide changesNoneYes

6. What can the native version patch?

The native version's biggest limitation: the dots and buttons are pseudo-elements, so you can't add HTML attributes to them. That means:

What you can't patch:

  • You can't give the tablist a name.
  • You can't swap disabled for aria-disabled.
  • You can't replace the tab semantics with buttons.
  • You can't change the Tab order or send focus straight to the current slide.

What you can patch: announcements. Browsers fire a scrollsnapchange event when scrolling snaps to a new slide; in my tests it fired both for button presses and for scrolling. Combine it with the approach from the ariaNotify() post:

carousel.addEventListener("scrollsnapchange", (event) => {
  const slide = event.snapTargetInline;
  if (slide) announce(carousel, slide.dataset.title);
});

But by this point you're writing JavaScript anyway, and the most important problem, focus, is still unsolved.

Conclusion

In a few lines:

  • ::scroll-marker and ::scroll-button() let CSS build a complete carousel interface with no JavaScript.
  • Alternative text can use attr(), so button and dot names can come from the HTML.
  • For accessibility, though, there are real gaps: the dot group has no name, it uses tab semantics, focus is lost at the ends and you're taken back to the first slide, and nothing is announced.
  • Most of these come from pseudo-elements themselves, and JavaScript can't patch them.

So my conclusion for now: a native CSS carousel can't yet replace a carefully built accessible carousel component. The layout techniques behind it, such as scroll-snap and container queries, are genuinely useful, and my <au-carousel> uses them for layout too. Once the spec lets these pseudo-elements adjust their semantics, or handles the end buttons in a way that doesn't drop focus, I'll happily come back and rewrite this post.

And the old advice still stands: avoid carousels if you can, and if you must use one, at least don't autoplay it.

Do you have carousels in your projects? Built in-house or from a library? I'd love to hear about it.