# Carousels that keyboard and screen reader users can't use

Source: https://easeweb.dev/learn/carousels
Topics: Complex widgets > Carousel arrows and dots work with a keyboard and say which slide is shown (WCAG 2.1.1, 4.1.2)
The fix: Build the arrows and dots as native buttons with names, mark the current dot with aria-current, and put the slides in a polite live region so each new slide is read out.
Test with: keyboard, screen reader, forced colors
References: https://www.w3.org/WAI/ARIA/apg/patterns/carousel/ https://www.w3.org/WAI/ARIA/apg/patterns/carousel/examples/carousel-1-prev-next/ https://www.w3.org/WAI/tutorials/carousels/ https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-live https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-current

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A city museum opens its homepage with a carousel of three slides: the autumn exhibition, late opening on Fridays, and membership. Each slide has a heading, a line of text and a link. An arrow at each side and three dots underneath change the slide on a click, and the dot for the slide on show is filled in. The arrows and dots are divs and spans with click handlers. Pressing Tab goes from the first slide's link straight to the next link on the page, so a keyboard user only ever sees the first slide. A screen reader never hears the arrows or dots, the filled dot is only a colour, and when a mouse user changes the slide, nothing is read out.

Many carousels get halfway: the arrows and dots are real buttons with names, so the keyboard can change the slide. Even then, a screen reader user who presses Next hears nothing at all, and on the dots hears "Slide 2, button" with no word of which slide is the current one. They can control the carousel but can't tell what it is showing.

## Why it fails

Success criterion 2.1.1 Keyboard asks that everything the mouse can do, the keyboard can do. Showing the second or third slide is done only by clicking a div or a span, which take no focus, so the keyboard can't do it at all. Success criterion 4.1.2 Name, Role, Value asks that controls expose a name, a role and their current state. The arrows and dots expose no name and no role, and the dots show which slide is current only by colour, a state that is never exposed.

Turning the arrows and dots into buttons is not enough on its own. The current dot still shows its state only by colour, which fails 4.1.2 in the same way. And pressing Next swaps the content beside the button while focus stays on it, so a screen reader user hears nothing and can't tell whether the press did anything, or which slide they are now on. The ARIA Authoring Practices carousel reads the new slide out through a polite live region, and the fix below does the same.

## Who is affected

The carousel is the first thing on the homepage, and the slides after the first hold the late opening and the membership offer. Anyone who can't use a mouse, or can't see the dots, never learns they are there.

## What automation and AI miss

Arrows and dots built from divs and spans have no role, so axe has nothing to check. The audit reports them when they sit inside an element whose class names it as a carousel, slider or slideshow, have class names such as `prev`, `next`, `arrow`, `dot` or `bullet`, show a pointer cursor and never take focus. That covers the arrows of Swiper without its accessibility module and the dots of older Owl and Flickity versions, but not arrows a site has named some other way. When the arrows are buttons with only an icon, axe reports the missing name as `button-name`. No check can tell whether the current dot says it is current, or whether a new slide is read out. AI fixes often add `role="button"` and `tabindex="0"` to the divs and stop there, which gives a Tab stop that Enter and Space do nothing on; put `aria-label` on a div with no role, which many screen readers ignore; or add `aria-live="assertive"` with a "Slide changed" message, which interrupts and still doesn't say what the slide is.

## Before: arrows and dots that take only clicks

```html
<div class="carousel">
  <div class="slides">
    <div class="slide">
      <h2>Paper and light</h2>
      <p>Our autumn exhibition, until 31 January.</p>
      <a href="/exhibitions/paper-and-light">See the exhibition</a>
    </div>
    <div class="slide" hidden>
      <h2>Open late on Fridays</h2>
      <p>Galleries and café open until 9 pm.</p>
      <a href="/visit">Plan your visit</a>
    </div>
    <div class="slide" hidden>
      <h2>Become a member</h2>
      <p>Free entry all year, and a first look at every new show.</p>
      <a href="/membership">Join today</a>
    </div>
  </div>
  <div class="carousel-prev"></div>
  <div class="carousel-next"></div>
  <div class="carousel-dots">
    <span class="carousel-dot is-current"></span>
    <span class="carousel-dot"></span>
    <span class="carousel-dot"></span>
  </div>
</div>
```

```css
.carousel-prev { background: url(chevron-left.svg) center / 24px no-repeat; }
.carousel-next { background: url(chevron-right.svg) center / 24px no-repeat; }
.carousel-dot { width: 12px; height: 12px; border: 2px solid currentColor; border-radius: 50%; }
/* The current dot is filled in, by a class only. */
.carousel-dot.is-current { background: currentColor; }
.carousel-prev, .carousel-next, .carousel-dot { cursor: pointer; }
```

```js
const carousel = document.querySelector('.carousel');
const slides = [...carousel.querySelectorAll('.slide')];
const dots = [...carousel.querySelectorAll('.carousel-dot')];
let current = 0;
function show(index) {
  current = (index + slides.length) % slides.length;
  slides.forEach((slide, i) => (slide.hidden = i !== current));
  dots.forEach((dot, i) => dot.classList.toggle('is-current', i === current));
}
carousel.querySelector('.carousel-prev').addEventListener('click', () => show(current - 1));
carousel.querySelector('.carousel-next').addEventListener('click', () => show(current + 1));
dots.forEach((dot, i) => dot.addEventListener('click', () => show(i)));
```

The arrows and dots respond only to `click`, with no Tab stop, no name and no role. The current dot is marked by a class that only changes its colour, and a new slide replaces the old one without a word to a screen reader.

## Before: buttons that work but say nothing

```html
<div class="carousel">
  <button type="button" class="carousel-prev">
    <svg aria-hidden="true" focusable="false"><use href="#icon-previous"></use></svg>
    <span class="visually-hidden">Previous slide</span>
  </button>
  <button type="button" class="carousel-next">
    <svg aria-hidden="true" focusable="false"><use href="#icon-next"></use></svg>
    <span class="visually-hidden">Next slide</span>
  </button>
  <div class="slides">
    <div class="slide">…</div>
    <div class="slide" hidden>…</div>
    <div class="slide" hidden>…</div>
  </div>
  <div class="carousel-dots">
    <button type="button" class="carousel-dot is-current"><span class="visually-hidden">Slide 1</span></button>
    <button type="button" class="carousel-dot"><span class="visually-hidden">Slide 2</span></button>
    <button type="button" class="carousel-dot"><span class="visually-hidden">Slide 3</span></button>
  </div>
</div>
```

```css
.carousel-dot { width: 24px; height: 24px; border: 2px solid currentColor; border-radius: 50%; background: none; }
/* The current dot is filled in, by a class only. */
.carousel-dot.is-current { background: currentColor; }
```

```js
// show() and the click handlers as before: the slides and the class change, nothing else.
function show(index) {
  current = (index + slides.length) % slides.length;
  slides.forEach((slide, i) => (slide.hidden = i !== current));
  dots.forEach((dot, i) => dot.classList.toggle('is-current', i === current));
}
```

Tab now reaches every arrow and dot, and Enter and Space press them, so the keyboard can show every slide. But the screen reader is no better off. The current dot is still marked only by a class, so it reads as "Slide 2, button" whether or not that slide is showing, and the slides sit in a plain div, so pressing Next or a dot changes the slide without a word. A screen reader user can control the carousel and still not know which slide is on screen.

## After: arrows and dots as buttons

```html
<section class="carousel" aria-roledescription="carousel" aria-label="Highlights">
  <button type="button" class="carousel-prev">
    <svg aria-hidden="true" focusable="false"><use href="#icon-previous"></use></svg>
    <span class="visually-hidden">Previous slide</span>
  </button>
  <button type="button" class="carousel-next">
    <svg aria-hidden="true" focusable="false"><use href="#icon-next"></use></svg>
    <span class="visually-hidden">Next slide</span>
  </button>
  <div class="slides" aria-live="polite">
    <div class="slide" role="group" aria-roledescription="slide" aria-label="1 of 3">
      <h2>Paper and light</h2>
      <p>Our autumn exhibition, until 31 January.</p>
      <a href="/exhibitions/paper-and-light">See the exhibition</a>
    </div>
    <div class="slide" role="group" aria-roledescription="slide" aria-label="2 of 3" hidden>
      <h2>Open late on Fridays</h2>
      <p>Galleries and café open until 9 pm.</p>
      <a href="/visit">Plan your visit</a>
    </div>
    <div class="slide" role="group" aria-roledescription="slide" aria-label="3 of 3" hidden>
      <h2>Become a member</h2>
      <p>Free entry all year, and a first look at every new show.</p>
      <a href="/membership">Join today</a>
    </div>
  </div>
  <div class="carousel-dots">
    <button type="button" class="carousel-dot" aria-current="true"><span class="visually-hidden">Slide 1</span></button>
    <button type="button" class="carousel-dot"><span class="visually-hidden">Slide 2</span></button>
    <button type="button" class="carousel-dot"><span class="visually-hidden">Slide 3</span></button>
  </div>
</section>
```

```css
.carousel-dot { width: 24px; height: 24px; border: 2px solid currentColor; border-radius: 50%; background: none; }
/* The current dot is drawn from the attribute a screen reader reads. */
.carousel-dot[aria-current='true'] { background: currentColor; }
```

```js
const carousel = document.querySelector('.carousel');
const slides = [...carousel.querySelectorAll('.slide')];
const dots = [...carousel.querySelectorAll('.carousel-dot')];
let current = 0;
function show(index) {
  current = (index + slides.length) % slides.length;
  slides.forEach((slide, i) => (slide.hidden = i !== current));
  dots.forEach((dot, i) =>
    i === current ? dot.setAttribute('aria-current', 'true') : dot.removeAttribute('aria-current'));
}
carousel.querySelector('.carousel-prev').addEventListener('click', () => show(current - 1));
carousel.querySelector('.carousel-next').addEventListener('click', () => show(current + 1));
dots.forEach((dot, i) => dot.addEventListener('click', () => show(i)));
```

The arrows and dots are real buttons, so Tab reaches them and Enter and Space press them, with no key handling to write, and each has a name. The arrows come before the slides, so the keyboard meets them first. The current dot carries `aria-current="true"`, so a screen reader says "current" on it, and its filled look is drawn from that attribute, so the look and the announcement can't disagree. The polite live region reads the new slide to screen reader users who press an arrow or a dot, without moving their focus, and the slide's label says which of the three it is.

## Implementation decisions

The dots are optional. When the arrows are enough, keep the dots as a picture of where you are, with `aria-hidden="true"` and no click handler, so there is nothing for the keyboard to miss. When they stay clickable, each one needs everything a button needs:

- a name, here "Slide 1", in hidden text or `aria-label`;
- a target of at least 24 by 24 pixels, which a 12-pixel dot is not;
- a visible focus ring, and 3:1 contrast against what is behind it;
- `aria-current="true"` on the current one, with its look drawn from that attribute rather than a class.

Keep the announcement short and polite. The live region reads the slide itself, its label and its content, so there is no need for a separate "Slide changed" message. `aria-live="assertive"` would cut off whatever the screen reader was saying.

`aria-roledescription` changes what a screen reader calls the region and each slide, so it goes on those two only, and in the page's language: "carousel" and "slide" in English, "カルーセル" and "スライド" in Japanese. The label "1 of 3" tells people where they are; the slide's heading says what it is about.

Swiping can stay, but it is an extra for touch screens, never the only way to change the slide. Many carousel libraries ship an accessibility option, such as Swiper's a11y module; turn it on, then check what it actually does with the steps below. The same applies to product carousels that show several cards at once: the arrows are named buttons, and what they bring into view is read out.

## Verify the fix

1. Put the mouse aside. Tab into the carousel: every arrow and dot is a Tab stop, Enter and Space press it, and each slide and its link can be reached.
2. With a screen reader, Tab through the carousel. Each arrow and dot says what it does, and the current dot says it is current.
3. Press Next and each dot with the screen reader on. The new slide is read out, and focus stays on the button.
4. Turn on forced colors (Windows Contrast themes). The arrows, the dots and which dot is current are all still visible.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the carousel, its slides or its library change.

## Limits

This is a starting pattern, not a guarantee for every application. It covers how the arrows and dots are reached and what they say. A carousel that also turns by itself needs a way to stop it as well (2.2.2): see [tickers, carousels and animations that can't be paused](/learn/moving-content), which also covers stopping the rotation when focus enters a slide. The text over each slide still needs enough contrast against its picture (1.4.3), and a slide's picture needs alt text when it carries information.
