Accessible carousels and sliders

Carousels are notoriously difficult to make accessible. They combine auto-playing content, complex keyboard interactions, and dynamic updates — all things that create barriers for screen reader and keyboard users.

Should you use a carousel?

Before building one, consider the evidence :

  • Less than 1% of users click on carousel slides (NN Group study)
  • Auto-rotating carousels are ranked as one of the most frustrating UX patterns
  • The first slide gets 84% of all clicks

If you must use a carousel, make it accessible.

Basic structure

<section aria-label="Featured articles" aria-roledescription="carousel">
    <div class="carousel-controls">
        <button aria-label="Previous slide" id="prev-btn"></button>
        <span aria-live="polite" id="slide-status">Slide 1 of 4</span>
        <button aria-label="Next slide" id="next-btn"></button>
    </div>
    
    <div class="carousel-viewport" aria-atomic="false" aria-live="off" id="carousel-live">
        <div role="group" aria-roledescription="slide" aria-label="1 of 4">
            <h3>First article title</h3>
            <p>Description of the first article.</p>
            <a href="/article-1">Read more</a>
        </div>
        <div role="group" aria-roledescription="slide" aria-label="2 of 4" hidden>
            <h3>Second article title</h3>
            <p>Description of the second article.</p>
            <a href="/article-2">Read more</a>
        </div>
    </div>
</section>

Key ARIA attributes

AttributeWherePurpose
aria-roledescription="carousel"ContainerTells screen readers this is a carousel
aria-roledescription="slide"Each slideIdentifies individual slides
aria-label="1 of 4"Each slideCurrent position
aria-live="polite"Status textAnnounces slide changes
aria-live="off"Slide containerPrevents auto-rotation from interrupting users

Keyboard navigation

KeyAction
TabMove focus into/out of the carousel
Left ArrowPrevious slide
Right ArrowNext slide
Enter / SpaceActivate a link within the slide
const carousel = document.querySelector('[aria-roledescription="carousel"]');

carousel.addEventListener('keydown', (e) => {
    switch (e.key) {
        case 'ArrowLeft':
            showPreviousSlide();
            e.preventDefault();
            break;
        case 'ArrowRight':
            showNextSlide();
            e.preventDefault();
            break;
    }
});

Auto-rotation

If the carousel auto-rotates, provide a pause button and stop on any user interaction :

<button aria-label="Stop auto-rotation" id="pause-btn"></button>
let autoPlayInterval;
let isPlaying = true;

function startAutoPlay() {
    autoPlayInterval = setInterval(showNextSlide, 5000);
    isPlaying = true;
    pauseBtn.textContent = '⏸';
    pauseBtn.setAttribute('aria-label', 'Stop auto-rotation');
}

function stopAutoPlay() {
    clearInterval(autoPlayInterval);
    isPlaying = false;
    pauseBtn.textContent = '▶';
    pauseBtn.setAttribute('aria-label', 'Start auto-rotation');
}

document.getElementById('pause-btn').addEventListener('click', () => {
    isPlaying ? stopAutoPlay() : startAutoPlay();
});

carousel.addEventListener('mouseenter', stopAutoPlay);
carousel.addEventListener('focusin', stopAutoPlay);

prefers-reduced-motion

Respect the user’s OS motion preference :

const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

if (reducedMotion.matches) {
    stopAutoPlay();
}

reducedMotion.addEventListener('change', (e) => {
    if (e.matches) stopAutoPlay();
});

Slide transitions

function showSlide(index) {
    const slides = document.querySelectorAll('[aria-roledescription="slide"]');
    const status = document.getElementById('slide-status');
    
    slides.forEach((slide, i) => {
        if (i === index) {
            slide.removeAttribute('hidden');
            slide.setAttribute('aria-label', `${i + 1} of ${slides.length}`);
        } else {
            slide.setAttribute('hidden', '');
        }
    });

    status.textContent = `Slide ${index + 1} of ${slides.length}`;
}

Tab-style indicators

<div role="tablist" aria-label="Slide controls">
    <button role="tab" aria-selected="true" aria-label="Slide 1"></button>
    <button role="tab" aria-selected="false" aria-label="Slide 2"></button>
    <button role="tab" aria-selected="false" aria-label="Slide 3"></button>
</div>
indicators.forEach((btn, i) => {
    btn.addEventListener('click', () => {
        showSlide(i);
        stopAutoPlay();
        
        indicators.forEach(b => b.setAttribute('aria-selected', 'false'));
        btn.setAttribute('aria-selected', 'true');
    });
});

Scrolling carousels (CSS Scroll Snap)

For simpler carousels where all slides are visible and scroll horizontally :

<div class="scroll-carousel" role="region" aria-label="Featured products" tabindex="0">
    <article>Product 1</article>
    <article>Product 2</article>
    <article>Product 3</article>
</div>
.scroll-carousel {
    display: flex;
    gap: 1rem;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    -webkit-overflow-scrolling: touch;
}

.scroll-carousel article {
    scroll-snap-align: start;
    min-width: 280px;
    flex-shrink: 0;
}

.scroll-carousel:focus-visible {
    outline: 2px solid #818cf8;
    outline-offset: 2px;
    border-radius: 0.5rem;
}

tabindex="0" makes the container focusable so keyboard users can scroll with arrow keys.

Common mistakes

  • No pause button — Auto-rotation without a stop mechanism fails WCAG 2.2.2.
  • Slides not announced — Without aria-live, screen readers don’t know content changed.
  • No keyboard support — Arrow keys should navigate between slides.
  • Hidden slides still focusable — Use hidden attribute or display: none to remove inactive slides from the tab order.
  • Dot indicators without labels — Screen readers announce “button” with no context. Add aria-label.

Resources

If you found this helpful, share it with someone who's building for the web.

- Vinay Ranjan