// 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.
// 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
<!-- 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">
‹
</button>
<button type="button" aria-label="Next slide">
›
</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>
.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;
}
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 |
// 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 usearia-pressedto 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
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.
<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.
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.
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.