---
title: "Is a CSS-Only Carousel Accessible Enough? Compared Against My UI Kit"
description: "::scroll-marker and ::scroll-button() let CSS build a carousel's dots and previous/next buttons with no JavaScript. But carousels have always been an accessibility trouble spot, so is the native version good enough? With identical content, this post puts a native CSS carousel next to the <au-carousel> I built for Accesserty UI Kit and tests the accessibility tree and keyboard behavior."
canonical_url: "https://neillin.com/en/blog/css-carousel-a11y/"
---

> Index of every page on this site: [llms.txt](https://neillin.com/llms.txt)

> 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](https://neillin.com/en/blog/css-techniques-checklist) 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](https://github.com/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:

<feature-support features="scroll-marker" notice="Your browser doesn't support ::scroll-marker, so the first demo below is just a row of cards you can scroll sideways. The second, UI Kit demo isn't affected.">



</feature-support>

## 1. Writing a native CSS carousel

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

```css
.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:

```css
/* 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);
}
```

```html
<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:

<carousel-native-demo>



</carousel-native-demo>

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:

```css
.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:

<table>
<thead>
  <tr>
    <th>
      On screen
    </th>
    
    <th>
      Role in the tree
    </th>
    
    <th>
      Name
    </th>
    
    <th>
      State
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Previous/next
    </td>
    
    <td>
      <code>
        button
      </code>
    </td>
    
    <td>
      Alternative text ("Previous slide")
    </td>
    
    <td>
      <code>
        disabled
      </code>
      
       at either end
    </td>
  </tr>
  
  <tr>
    <td>
      The group of dots
    </td>
    
    <td>
      <code>
        tablist
      </code>
    </td>
    
    <td>
      <strong>
        No name
      </strong>
    </td>
    
    <td>
      —
    </td>
  </tr>
  
  <tr>
    <td>
      Each dot
    </td>
    
    <td>
      <code>
        tab
      </code>
    </td>
    
    <td>
      Alternative text ("Kyoto")
    </td>
    
    <td>
      <code>
        selected
      </code>
      
       on the current slide
    </td>
  </tr>
  
  <tr>
    <td>
      Each slide
    </td>
    
    <td>
      Stays a <code>
        listitem
      </code>
    </td>
    
    <td>
      —
    </td>
    
    <td>
      —
    </td>
  </tr>
</tbody>
</table>

Two things stand out:

- **The dots use tab semantics**: they become `tab`s, but the slides don't become matching `tabpanel`s; 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:

<carousel-au-demo>



</carousel-au-demo>

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

<table>
<thead>
  <tr>
    <th>
      Aspect
    </th>
    
    <th>
      Native CSS
    </th>
    
    <th>
      <code>
        <au-carousel>
      </code>
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Needs JavaScript
    </td>
    
    <td>
      No
    </td>
    
    <td>
      Yes
    </td>
  </tr>
  
  <tr>
    <td>
      Browsers
    </td>
    
    <td>
      Chromium only, for now
    </td>
    
    <td>
      Not limited to Chromium (a regular web component)
    </td>
  </tr>
  
  <tr>
    <td>
      Name for the carousel
    </td>
    
    <td>
      Add <code>
        aria-label
      </code>
      
       to the container yourself
    </td>
    
    <td>
      Yes, plus a role description
    </td>
  </tr>
  
  <tr>
    <td>
      Dot semantics
    </td>
    
    <td>
      <code>
        tablist
      </code>
      
      /<code>
        tab
      </code>
    </td>
    
    <td>
      Plain buttons + <code>
        aria-current
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Name for the dot group
    </td>
    
    <td>
      None
    </td>
    
    <td>
      Yes, plus instructions
    </td>
  </tr>
  
  <tr>
    <td>
      Dot names
    </td>
    
    <td>
      Alternative text
    </td>
    
    <td>
      Title + position ("1 of 4")
    </td>
  </tr>
  
  <tr>
    <td>
      Arrow keys
    </td>
    
    <td>
      Yes, wrapping at the ends
    </td>
    
    <td>
      Yes, stopping at the ends, plus Home/End
    </td>
  </tr>
  
  <tr>
    <td>
      Entering the content
    </td>
    
    <td>
      DOM order, from the first slide
    </td>
    
    <td>
      Straight to the current slide
    </td>
  </tr>
  
  <tr>
    <td>
      End buttons
    </td>
    
    <td>
      <code>
        disabled
      </code>
      
      , focus is lost
    </td>
    
    <td>
      <code>
        aria-disabled
      </code>
      
      , focus stays
    </td>
  </tr>
  
  <tr>
    <td>
      Announcing slide changes
    </td>
    
    <td>
      None
    </td>
    
    <td>
      Yes
    </td>
  </tr>
</tbody>
</table>

## 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](https://neillin.com/en/blog/aria-notify/):

```js
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.

## Related reading

- [Make Screen Readers Speak: Replacing aria-live with ariaNotify()](https://neillin.com/en/blog/aria-notify/)
- [Tooltip Getting Cut Off? Let the Browser Find a Spot with @position-try](https://neillin.com/en/blog/position-try/)
- [Modern CSS & HTML Cheat Sheet: 115 Features, Support & What to Try Next](https://neillin.com/en/blog/css-techniques-checklist/)

## Related links

- [Accesserty UI Kit (GitHub)](https://github.com/Accesserty/UI-Kit)
- [MDN — ::scroll-marker](https://developer.mozilla.org/en-US/docs/Web/CSS/::scroll-marker)
- [MDN — ::scroll-button()](https://developer.mozilla.org/en-US/docs/Web/CSS/::scroll-button)
- [Chrome for Developers — Carousels with CSS](https://developer.chrome.com/blog/carousels-with-css)
- [WAI-ARIA APG — Carousel Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/)
- [Can I use](https://caniuse.com)

## Sitemap

See the full [sitemap](https://neillin.com/sitemap.md) for all pages.
