// pattern

Accessible Carousel / Slider Pattern

A fully accessible carousel with auto-rotation controls, keyboard navigation, and screen reader announcements. Pause on hover or focus, navigate with arrows, and hear slide changes announced via a live region.

aria-required wcag-2.2-aa intermediate

// 01 · live demo

Live Demo

Use the Previous / Next buttons or dot indicators to navigate slides. The Play / Pause button controls auto-rotation. Auto-rotation pauses when you hover over or focus within the carousel.

// 02 · the code

The Code

HTML
<!-- Carousel wrapper -->
<div class="carousel" role="group"
     aria-roledescription="carousel"
     aria-label="Featured Patterns">

  <!-- Slide track -->
  <div class="carousel__track">
    <div class="carousel__slide" role="group"
         aria-roledescription="slide"
         aria-label="1 of 4">
      <!-- Slide content -->
    </div>
    <div class="carousel__slide" role="group"
         aria-roledescription="slide"
         aria-label="2 of 4">
      <!-- Slide content -->
    </div>
    <!-- ...more slides -->
  </div>

  <!-- Live region for screen reader announcements -->
  <div aria-live="polite" aria-atomic="true"
       class="sr-only" id="carousel-live"></div>
</div>

<!-- Navigation -->
<button type="button" aria-label="Previous slide">
  &lsaquo;
</button>
<button type="button" aria-label="Next slide">
  &rsaquo;
</button>

<!-- Dot indicators -->
<div role="group" aria-label="Slide navigation">
  <button aria-label="Go to slide 1"
          aria-pressed="true"></button>
  <button aria-label="Go to slide 2"
          aria-pressed="false"></button>
  <!-- ...more dots -->
</div>

<!-- Play / Pause control -->
<button type="button"
        aria-label="Start auto-rotation">
  Play
</button>
CSS
.carousel {
  position: relative;
  overflow: hidden;
  border-radius: 0.75rem;
  border: 1px solid #d1d5db;
}

.carousel__track {
  display: flex;
  transition: transform 0.4s ease;
}

/* Honour prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
  .carousel__track {
    transition: none;
  }
}

.carousel__slide {
  min-width: 100%;
  padding: 2rem;
  box-sizing: border-box;
}

/* Navigation arrows */
.carousel-nav {
  width: 2.75rem;
  height: 2.75rem;
  border: 1px solid #d1d5db;
  background: #fff;
  border-radius: 50%;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

.carousel-nav:focus-visible {
  outline: 2px solid #5b2a86;
  outline-offset: 2px;
}

/* Dot indicators */
.carousel-dot {
  width: 0.75rem;
  height: 0.75rem;
  border-radius: 50%;
  border: 2px solid #6b7280;
  background: transparent;
  cursor: pointer;
  padding: 0;
}

.carousel-dot[aria-pressed="true"] {
  background: #5b2a86;
  border-color: #5b2a86;
}

/* Visually hidden live region */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
JavaScript
const carousel = document.querySelector('.carousel');
const track = carousel.querySelector('.carousel__track');
const slides = carousel.querySelectorAll('.carousel__slide');
const dots = document.querySelectorAll('.carousel-dot');
const prevBtn = document.querySelector('.carousel-prev');
const nextBtn = document.querySelector('.carousel-next');
const playPauseBtn = document.querySelector('.carousel-playpause');
const liveRegion = document.getElementById('carousel-live');

let currentSlide = 0;
let isPlaying = false;
let autoRotateTimer = null;
const INTERVAL = 5000; // 5 seconds

function goToSlide(index) {
  currentSlide = (index + slides.length) % slides.length;
  track.style.transform =
    `translateX(-${currentSlide * 100}%)`;

  // Update dot indicators
  dots.forEach((dot, i) => {
    dot.setAttribute('aria-pressed',
      i === currentSlide ? 'true' : 'false');
  });

  // Update slide aria-labels
  slides.forEach((slide, i) => {
    slide.setAttribute('aria-label',
      `${i + 1} of ${slides.length}`);
  });

  // Announce to screen readers
  liveRegion.textContent =
    `Slide ${currentSlide + 1} of ${slides.length}`;
}

prevBtn.addEventListener('click', () => {
  goToSlide(currentSlide - 1);
});

nextBtn.addEventListener('click', () => {
  goToSlide(currentSlide + 1);
});

dots.forEach((dot, i) => {
  dot.addEventListener('click', () => goToSlide(i));
});

// Auto-rotation
function startAutoRotate() {
  isPlaying = true;
  autoRotateTimer = setInterval(() => {
    goToSlide(currentSlide + 1);
  }, INTERVAL);
  playPauseBtn.setAttribute('aria-label',
    'Stop auto-rotation');
}

function stopAutoRotate() {
  isPlaying = false;
  clearInterval(autoRotateTimer);
  playPauseBtn.setAttribute('aria-label',
    'Start auto-rotation');
}

playPauseBtn.addEventListener('click', () => {
  if (isPlaying) stopAutoRotate();
  else startAutoRotate();
});

// Pause on hover and focus (SC 2.2.2)
carousel.addEventListener('mouseenter', () => {
  if (isPlaying) clearInterval(autoRotateTimer);
});
carousel.addEventListener('mouseleave', () => {
  if (isPlaying) {
    autoRotateTimer = setInterval(() => {
      goToSlide(currentSlide + 1);
    }, INTERVAL);
  }
});
carousel.addEventListener('focusin', () => {
  if (isPlaying) clearInterval(autoRotateTimer);
});
carousel.addEventListener('focusout', () => {
  if (isPlaying) {
    autoRotateTimer = setInterval(() => {
      goToSlide(currentSlide + 1);
    }, INTERVAL);
  }
});

// 03 · why these decisions

Why These Decisions

Why role="group" on each slide?

Each slide is a logical grouping of content. Using role="group" with aria-roledescription="slide" lets screen readers announce "slide, 1 of 4" when the user navigates to it, providing clear positional context. Without role="group", the aria-roledescription attribute has no effect.

Why aria-roledescription="carousel" on the wrapper?

The aria-roledescription attribute overrides the default role announcement. Instead of hearing "group," screen reader users hear "carousel" — giving them immediate context about the widget type and the expected interaction model. This attribute should only be used when the standard role name would be confusing.

Why a pause button? (SC 2.2.2)

WCAG Success Criterion 2.2.2 Pause, Stop, Hide requires that any auto-updating content can be paused, stopped, or hidden by the user. An auto-rotating carousel is moving content — without a pause button, users who need more time to read (including screen reader users and people with cognitive disabilities) cannot control the pace.

Why aria-live="polite" for slide announcements?

When the slide changes, the live region announces the new slide position. Using polite (not assertive) ensures the announcement waits until the screen reader finishes its current output, avoiding interruption. The aria-atomic="true" attribute ensures the entire region text is announced, not just the changed part.

Why not auto-rotate by default?

Auto-rotating carousels create multiple accessibility barriers: they move focus targets, distract users with cognitive disabilities, and force time pressure on users who read slowly. This pattern starts paused and lets the user opt in to auto-rotation — a far more accessible default. If your design requires auto-rotation on load, ensure the pause button is prominently placed and keyboard-accessible.

// 04 · keyboard interaction

Keyboard Interaction

Key Action
Tab Moves focus through carousel controls: previous button, next button, dot indicators, play/pause button
Shift + Tab Moves focus backwards through carousel controls
Enter or Space Activates the focused control — navigates to a slide or toggles play/pause
Arrow Left When focus is on a dot indicator, moves to the previous slide
Arrow Right When focus is on a dot indicator, moves to the next slide
Focus Management When auto-rotation is active and the user moves focus into the carousel, auto-rotation pauses automatically. This prevents the disorienting experience of content changing while a user is trying to interact with it.

// 05 · wcag 2.2 success criteria

WCAG 2.2 Success Criteria

This pattern satisfies the following WCAG 2.2 success criteria:

  • 1.3.1 Info and Relationships Level A — The carousel structure is conveyed through ARIA roles (role="group") and role descriptions (aria-roledescription), ensuring the relationship between slides is programmatically determinable.
  • 2.1.1 Keyboard Level A — All carousel functionality is operable via keyboard. Navigate slides, activate dot indicators, and toggle play/pause without a mouse.
  • 2.2.2 Pause, Stop, Hide Level A — The play/pause button provides a mechanism to stop auto-rotation. The carousel also pauses on hover and focus, giving users full control over moving content.
  • 2.4.3 Focus Order Level A — Controls follow a logical tab order: previous, next, dot indicators, play/pause. Focus does not jump unexpectedly during slide transitions.
  • 4.1.2 Name, Role, Value Level A — All controls have accessible names via aria-label. The carousel and slides have appropriate roles and role descriptions. Dot indicators use aria-pressed to convey state.

// 06 · screen reader behavior

Screen Reader Behavior

When entering the carousel

  • NVDA: "Featured Patterns, carousel" — announces the label and role description
  • JAWS: "Featured Patterns carousel" — similar announcement
  • VoiceOver: "Featured Patterns, carousel" — announces the custom role description

When navigating slides

  • NVDA: "slide, 2 of 4" followed by slide content — announces the role description and position
  • JAWS: "slide 2 of 4" — reads the aria-label as the group name
  • VoiceOver: "2 of 4, slide" — announces position then role description

When slides change (via live region)

The aria-live="polite" region announces "Slide 2 of 4" after the transition. This provides feedback for both manual navigation and auto-rotation, so screen reader users always know which slide is currently displayed.

Play/pause button state

The button's aria-label updates dynamically between "Start auto-rotation" and "Stop auto-rotation," giving screen reader users clear context about what the button will do when activated.

// 07 · common mistakes

Common Mistakes

No pause mechanism for auto-rotating carousels Auto-rotating content without a pause button violates SC 2.2.2. Users with cognitive disabilities may not be able to read content before it changes. Screen reader users may hear partial content. Always provide a visible, keyboard-accessible pause control.
Using aria-roledescription without a valid role The aria-roledescription attribute only works on elements that have an explicit or implicit ARIA role. Applying it to a bare <div> without role="group" (or another valid role) means screen readers will ignore the description entirely.
Moving focus during auto-rotation Some carousel implementations move focus to the new slide during auto-rotation. This is disorienting — focus should only move in response to a deliberate user action, never as a result of a timer. Update the visual presentation, but leave focus where it is.
Dot indicators without accessible names Dots that are just styled <span> elements or buttons without aria-label are invisible to screen reader users. Each dot must be a <button> with an accessible name like "Go to slide 2" and aria-pressed to indicate the active slide.
No aria-live announcement on slide change Without a live region, screen reader users have no way of knowing that the displayed slide has changed. They miss content updates entirely. Always use aria-live="polite" with aria-atomic="true" to announce the current slide position.
Ignoring prefers-reduced-motion Slide transitions with animation can cause discomfort for users with vestibular disorders. Wrap transition CSS in a @media (prefers-reduced-motion: no-preference) query, or remove transitions when prefers-reduced-motion: reduce is active.