Skip to main content
Beta: Front-End Checklist is currently in beta. Some issues are still being fixed. Thanks for your patience.

Make carousels accessible

Carousels and sliders are accessible with pause controls, keyboard navigation, and proper ARIA attributes.

Utilities
Quick take
Typical fix time 30 min
  • Always provide pause/stop controls for auto-rotating carousels
  • Support keyboard navigation with arrow keys and tab
  • Use aria-live regions to announce slide changes
  • Respect prefers-reduced-motion preference
Why it matters: Auto-playing carousels without controls trap users, cause disorientation, and violate WCAG requirements—proper implementation ensures all users can interact with slideshow content.

Rule Details

Accessible carousels require careful implementation to support keyboard users, screen readers, and motion preferences.

Code Example

<section
  aria-roledescription="carousel"
  aria-label="Featured products"
  class="carousel"
>
  <!-- Controls -->
  <div class="carousel__controls">
    <button
      type="button"
      aria-label="Previous slide"
      class="carousel__prev"
    >

    </button>
 
    <button
      type="button"
      aria-pressed="false"
      aria-label="Pause carousel"
      class="carousel__pause"
    >

    </button>
 
    <button
      type="button"
      aria-label="Next slide"
      class="carousel__next"
    >

    </button>
  </div>
 
  <!-- Slides container -->
  <div
    aria-live="polite"
    aria-atomic="false"
    class="carousel__slides"
  >
    <div
      role="group"
      aria-roledescription="slide"
      aria-label="1 of 3"
      class="carousel__slide carousel__slide--active"
    >
      <img src="product1.jpg" alt="Product 1 description">
      <h3>Product 1</h3>
    </div>
 
    <div
      role="group"
      aria-roledescription="slide"
      aria-label="2 of 3"
      class="carousel__slide"
      hidden
    >
      <img src="product2.jpg" alt="Product 2 description">
      <h3>Product 2</h3>
    </div>
 
    <div
      role="group"
      aria-roledescription="slide"
      aria-label="3 of 3"
      class="carousel__slide"
      hidden
    >
      <img src="product3.jpg" alt="Product 3 description">
      <h3>Product 3</h3>
    </div>
  </div>
 
  <!-- Slide indicators -->
  <div role="tablist" aria-label="Slides" class="carousel__indicators">
    <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>
</section>

Why It Matters

Auto-playing carousels without controls trap users, cause disorientation, and violate WCAG requirements—proper implementation ensures all users can interact with slideshow content.

Accessibility Requirements

RequirementImplementation
Pause controlButton to stop/start auto-rotation
Keyboard navigationArrow keys, Tab between controls
Screen reader supportLive regions, slide announcements
Motion preferencesRespect prefers-reduced-motion
Visible focusClear focus indicators
import { useState, useEffect, useRef, useCallback } from 'react'
 
interface Slide {
  id: string
  image: string
  alt: string
  title: string
}
 
interface CarouselProps {
  slides: Slide[]
  autoPlay?: boolean
  interval?: number
  label: string
}
 
export function Carousel({
  slides,
  autoPlay = true,
  interval = 5000,
  label
}: CarouselProps) {
  const [currentIndex, setCurrentIndex] = useState(0)
  const [isPlaying, setIsPlaying] = useState(autoPlay)
  const [isPaused, setIsPaused] = useState(false)
  const carouselRef = useRef<HTMLDivElement>(null)
  const intervalRef = useRef<NodeJS.Timeout>()
 
  // Check for reduced motion preference
  const prefersReducedMotion = useRef(
    typeof window !== 'undefined' &&
    window.matchMedia('(prefers-reduced-motion: reduce)').matches
  )
 
  const goToSlide = useCallback((index: number) => {
    setCurrentIndex((index + slides.length) % slides.length)
  }, [slides.length])
 
  const nextSlide = useCallback(() => {
    goToSlide(currentIndex + 1)
  }, [currentIndex, goToSlide])
 
  const prevSlide = useCallback(() => {
    goToSlide(currentIndex - 1)
  }, [currentIndex, goToSlide])
 
  const togglePlay = () => {
    setIsPlaying(prev => !prev)
  }
 
  // Auto-play logic
  useEffect(() => {
    if (isPlaying && !isPaused && !prefersReducedMotion.current) {
      intervalRef.current = setInterval(nextSlide, interval)
    }
 
    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current)
      }
    }
  }, [isPlaying, isPaused, interval, nextSlide])
 
  // Keyboard navigation
  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowLeft':
        e.preventDefault()
        prevSlide()
        break
      case 'ArrowRight':
        e.preventDefault()
        nextSlide()
        break
      case 'Home':
        e.preventDefault()
        goToSlide(0)
        break
      case 'End':
        e.preventDefault()
        goToSlide(slides.length - 1)
        break
    }
  }
 
  // Pause on hover/focus
  const handleMouseEnter = () => setIsPaused(true)
  const handleMouseLeave = () => setIsPaused(false)
  const handleFocus = () => setIsPaused(true)
  const handleBlur = (e: React.FocusEvent) => {
    if (!carouselRef.current?.contains(e.relatedTarget)) {
      setIsPaused(false)
    }
  }
 
  return (
    <section
      ref={carouselRef}
      aria-roledescription="carousel"
      aria-label={label}
      className="carousel"
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
      onFocus={handleFocus}
      onBlur={handleBlur}
      onKeyDown={handleKeyDown}
    >
      {/* Controls */}
      <div className="carousel__controls">
        <button
          type="button"
          onClick={prevSlide}
          aria-label="Previous slide"
          className="carousel__prev"
        >

        </button>
 
        <button
          type="button"
          onClick={togglePlay}
          aria-pressed={!isPlaying}
          aria-label={isPlaying ? 'Pause carousel' : 'Play carousel'}
          className="carousel__pause"
        >
          {isPlaying ? '⏸' : '▶'}
        </button>
 
        <button
          type="button"
          onClick={nextSlide}
          aria-label="Next slide"
          className="carousel__next"
        >

        </button>
      </div>
 
      {/* Slides */}
      <div
        aria-live={isPlaying ? 'off' : 'polite'}
        aria-atomic="false"
        className="carousel__slides"
      >
        {slides.map((slide, index) => (
          <div
            key={slide.id}
            role="group"
            aria-roledescription="slide"
            aria-label={`${index + 1} of ${slides.length}`}
            aria-hidden={index !== currentIndex}
            className={`carousel__slide ${
              index === currentIndex ? 'carousel__slide--active' : ''
            }`}
          >
            <img src={slide.image} alt={slide.alt} />
            <h3>{slide.title}</h3>
          </div>
        ))}
      </div>
 
      {/* Indicators */}
      <div role="tablist" aria-label="Slides" className="carousel__indicators">
        {slides.map((slide, index) => (
          <button
            key={slide.id}
            role="tab"
            aria-selected={index === currentIndex}
            aria-label={`Slide ${index + 1}`}
            onClick={() => goToSlide(index)}
            className={`carousel__indicator ${
              index === currentIndex ? 'carousel__indicator--active' : ''
            }`}
          />
        ))}
      </div>
    </section>
  )
}

Reduced Motion Support

import { useEffect, useState } from 'react'
 
function useReducedMotion() {
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
 
  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
    setPrefersReducedMotion(mediaQuery.matches)
 
    const handler = (e: MediaQueryListEvent) => {
      setPrefersReducedMotion(e.matches)
    }
 
    mediaQuery.addEventListener('change', handler)
    return () => mediaQuery.removeEventListener('change', handler)
  }, [])
 
  return prefersReducedMotion
}
 
// Usage in carousel
function AccessibleCarousel({ slides }: { slides: Slide[] }) {
  const prefersReducedMotion = useReducedMotion()
  const [autoPlay, setAutoPlay] = useState(!prefersReducedMotion)
 
  // Disable auto-play when reduced motion is preferred
  useEffect(() => {
    if (prefersReducedMotion) {
      setAutoPlay(false)
    }
  }, [prefersReducedMotion])
 
  return (
    <Carousel
      slides={slides}
      autoPlay={autoPlay}
      label="Product showcase"
    />
  )
}

Styling

.carousel {
  position: relative;
  max-width: 800px;
  margin: 0 auto;
}
 
.carousel__slides {
  position: relative;
  overflow: hidden;
}
 
.carousel__slide {
  display: none;
}
 
.carousel__slide--active {
  display: block;
}
 
.carousel__controls {
  display: flex;
  justify-content: center;
  gap: 0.5rem;
  margin-top: 1rem;
}
 
.carousel__prev,
.carousel__next,
.carousel__pause {
  padding: 0.5rem 1rem;
  font-size: 1.25rem;
  background: #f5f5f5;
  border: 1px solid #ddd;
  border-radius: 4px;
  cursor: pointer;
}
 
.carousel__prev:hover,
.carousel__next:hover,
.carousel__pause:hover {
  background: #e0e0e0;
}
 
.carousel__prev:focus-visible,
.carousel__next:focus-visible,
.carousel__pause:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}
 
.carousel__indicators {
  display: flex;
  justify-content: center;
  gap: 0.5rem;
  margin-top: 1rem;
}
 
.carousel__indicator {
  width: 12px;
  height: 12px;
  padding: 0;
  border: 2px solid #333;
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
}
 
.carousel__indicator--active {
  background: #333;
}
 
.carousel__indicator:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}
 
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  .carousel__slide {
    transition: none;
  }
}

Verification

  1. Verify pause button stops auto-rotation
  2. Test keyboard navigation (arrows, Home, End)
  3. Check live region announces slide changes
  4. Verify motion respects prefers-reduced-motion
  5. Test with screen reader (slide count announced)
  6. Verify focus stays within carousel during keyboard nav
  7. Check indicators update with slide changes
Auto-Play Concerns

WCAG 2.2.2 requires auto-moving content to have pause, stop, or hide controls. Consider whether auto-play is truly necessary—many usability studies show carousels have low engagement.

Use with AI

Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.

Check

Verify implementation

Verify carousels have pause/play controls, keyboard navigation, proper ARIA roles, and respect prefers-reduced-motion.

Fix

Auto-fix issues

Implement carousels with pause buttons, arrow key navigation, aria-live for announcements, and motion preference support.

Explain

Learn more

Explain accessibility requirements for carousels including auto-play controls, keyboard access, and screen reader support.

Review

Code review

Review templates, server-rendered HTML, and shared components that output markup related to Make carousels accessible. Flag exact elements, attributes, and routes where the rendered HTML violates the rule.

Sources

References used to support the guidance in this rule.

Further Reading

Tools and supplementary material for exploring the topic in more depth.

Nu Html Checker
validator.w3.orgTool
Carousel Pattern – UX Patterns for Developers

Comprehensive UX pattern guide covering anatomy, accessibility, best practices, and implementation.

uxpatterns.devGuide

Rules that often go hand-in-hand with this one.

Make accordions keyboard navigable

Accordion components use proper ARIA attributes and keyboard interactions for screen reader accessibility.

Accessibility
Make notifications accessible

Toast notifications and alerts are announced to screen readers using ARIA live regions and appropriate roles.

Accessibility
Make tabs keyboard navigable

Tab components implement the ARIA tabs pattern with proper roles, states, and keyboard navigation.

Accessibility
Create accessible tooltips

Tooltips are accessible to keyboard users and screen readers with proper ARIA attributes and focus handling.

Accessibility

Was this rule helpful?

Your feedback helps improve rule quality. This stays internal for now.

Loading feedback...
0 / 385