APG Patterns
日本語 GitHub
日本語 GitHub

Carousel

A rotating set of content items (slides) displayed one at a time with controls to navigate between them.

🤖 AI Implementation Guide

Demo

Manual Navigation

Navigate using the tab indicators, previous/next buttons, or keyboard arrows.

Auto-Rotation

Automatically rotates through slides. Pauses on hover, focus, or when the user clicks the pause button. Respects prefers-reduced-motion.

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
region Container (section) Landmark region for the carousel
group Slides container Groups all slides together
tablist Tab container Container for slide indicator tabs
tab Each tab button Individual slide indicator
tabpanel Each slide Individual slide content area

WAI-ARIA APG Carousel Pattern (opens in new tab)

WAI-ARIA Properties

Attribute Target Values Required Description
aria-roledescription Container "carousel" Yes Announces "carousel" to screen readers
aria-roledescription Each slide (tabpanel) "slide" Yes Announces "slide" instead of "tabpanel"
aria-label Container Text Yes Describes the carousel purpose
aria-label Each slide (tabpanel) "N of M" Yes Slide position (e.g., "1 of 5")
aria-controls Tab, Prev/Next buttons ID reference Yes References controlled element
aria-labelledby Each slide (tabpanel) ID reference Yes References associated tab
aria-atomic Slides container "false" No Only announce changed content

WAI-ARIA States

aria-selected

Indicates the currently selected slide indicator tab.

Target Tab element
Values true | false
Required Yes
Change Trigger Tab click, Arrow keys, Prev/Next buttons, Auto-rotation
Reference aria-selected (opens in new tab)

aria-live

Dynamically controls screen reader announcements based on rotation state.

Target Slides container
Values "off" (auto-rotating) | "polite" (manual/paused)
Required Yes (when auto-rotation enabled)
Change Trigger Play/Pause click, Focus in/out, Mouse hover
Reference aria-live (opens in new tab)

Note: Set to "off" during auto-rotation to prevent interrupting users. Changes to "polite" when rotation stops, allowing slide changes to be announced.

Keyboard Support

Key Action
Tab Navigate between controls (Play/Pause, tablist, Prev/Next)
Arrow Right Move to next slide indicator tab (loops to first)
Arrow Left Move to previous slide indicator tab (loops to last)
Home Move focus to first slide indicator tab
End Move focus to last slide indicator tab
Enter / Space Activate focused tab or button

Auto-Rotation Behavior

Trigger Behavior
Keyboard focus enters carousel Rotation pauses temporarily, aria-live changes to "polite"
Keyboard focus leaves carousel Rotation resumes (if auto-rotate mode is on)
Mouse hovers over slides Rotation pauses temporarily
Mouse leaves slides Rotation resumes (if auto-rotate mode is on)
Pause button clicked Turns off auto-rotate mode, button shows play icon
Play button clicked Turns on auto-rotate mode and starts rotation immediately
prefers-reduced-motion: reduce Auto-rotation disabled by default

Source Code

Carousel.tsx
import { useCallback, useEffect, useId, useRef, useState } from 'react';

export interface CarouselSlide {
  /** Unique identifier for the slide */
  id: string;
  /** Slide content (JSX or string) */
  content: React.ReactNode;
  /** Accessible label for the slide */
  label?: string;
}

export interface CarouselProps {
  /** Array of slides */
  slides: CarouselSlide[];
  /** Accessible label for the carousel (required) */
  'aria-label': string;
  /** Initial slide index (0-based) */
  initialSlide?: number;
  /** Enable auto-rotation */
  autoRotate?: boolean;
  /** Rotation interval in milliseconds (default: 5000) */
  rotationInterval?: number;
  /** Callback when slide changes */
  onSlideChange?: (index: number) => void;
  /** Additional CSS class */
  className?: string;
  /** Test ID for E2E testing */
  'data-testid'?: string;
}

export function Carousel({
  slides,
  'aria-label': ariaLabel,
  initialSlide = 0,
  autoRotate = false,
  rotationInterval = 5000,
  onSlideChange,
  className = '',
  'data-testid': testId,
}: CarouselProps): React.ReactElement {
  // Validate initialSlide - fallback to 0 if out of bounds
  const validInitialSlide = initialSlide >= 0 && initialSlide < slides.length ? initialSlide : 0;

  const [currentSlide, setCurrentSlide] = useState(validInitialSlide);
  const [focusedIndex, setFocusedIndex] = useState(validInitialSlide);

  // Transition animation state
  const [exitingSlide, setExitingSlide] = useState<number | null>(null);
  const [transitionDirection, setTransitionDirection] = useState<'next' | 'prev' | null>(null);

  // New state model: separate user intent from temporary pause
  const [autoRotateMode, setAutoRotateMode] = useState(() => {
    // Check prefers-reduced-motion
    if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
      const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      if (prefersReducedMotion) {
        return false;
      }
    }
    return autoRotate;
  });
  const [isPausedByInteraction, setIsPausedByInteraction] = useState(false);

  const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
  const tablistRef = useRef<HTMLDivElement>(null);
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
  const animationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const currentSlideRef = useRef(currentSlide);

  const carouselId = useId();
  const slidesContainerId = `${carouselId}-slides`;

  // Computed: actual rotation state
  const isActuallyRotating = autoRotateMode && !isPausedByInteraction;

  // Handle slide change with animation
  const goToSlide = useCallback(
    (index: number) => {
      if (slides.length < 2) return;
      const newIndex = ((index % slides.length) + slides.length) % slides.length;
      if (newIndex === currentSlide) return;

      // Determine direction based on index change
      // Special case for wrap-around
      const isWrapForward = currentSlide === slides.length - 1 && newIndex === 0;
      const isWrapBackward = currentSlide === 0 && newIndex === slides.length - 1;
      const direction =
        isWrapForward || (!isWrapBackward && newIndex > currentSlide) ? 'next' : 'prev';

      // Start transition
      setExitingSlide(currentSlide);
      setTransitionDirection(direction);
      setCurrentSlide(newIndex);
      setFocusedIndex(newIndex);
      onSlideChange?.(newIndex);

      // Clean up after animation
      animationTimeoutRef.current = setTimeout(() => {
        setExitingSlide(null);
        setTransitionDirection(null);
        animationTimeoutRef.current = null;
      }, 300); // Match CSS animation duration
    },
    [slides.length, currentSlide, onSlideChange]
  );

  const goToNextSlide = useCallback(() => {
    goToSlide(currentSlide + 1);
  }, [currentSlide, goToSlide]);

  const goToPrevSlide = useCallback(() => {
    goToSlide(currentSlide - 1);
  }, [currentSlide, goToSlide]);

  // Focus management
  const handleTabFocus = useCallback(
    (index: number) => {
      setFocusedIndex(index);
      const slide = slides[index];
      if (slide) {
        tabRefs.current.get(slide.id)?.focus();
      }
    },
    [slides]
  );

  // Keyboard handler for tablist
  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent) => {
      const { key } = event;
      const target = event.target;
      if (
        !tablistRef.current ||
        !(target instanceof Node) ||
        !tablistRef.current.contains(target)
      ) {
        return;
      }

      let newIndex = focusedIndex;
      let shouldPreventDefault = false;

      switch (key) {
        case 'ArrowRight':
          newIndex = (focusedIndex + 1) % slides.length;
          shouldPreventDefault = true;
          break;

        case 'ArrowLeft':
          newIndex = (focusedIndex - 1 + slides.length) % slides.length;
          shouldPreventDefault = true;
          break;

        case 'Home':
          newIndex = 0;
          shouldPreventDefault = true;
          break;

        case 'End':
          newIndex = slides.length - 1;
          shouldPreventDefault = true;
          break;

        case 'Enter':
        case ' ':
          // Tab is already selected on focus, but this handles manual confirmation
          goToSlide(focusedIndex);
          shouldPreventDefault = true;
          break;
      }

      if (shouldPreventDefault) {
        event.preventDefault();

        if (newIndex !== focusedIndex) {
          handleTabFocus(newIndex);
          goToSlide(newIndex);
        }
      }
    },
    [focusedIndex, slides.length, handleTabFocus, goToSlide]
  );

  // Keep ref in sync with state
  useEffect(() => {
    currentSlideRef.current = currentSlide;
  }, [currentSlide]);

  // Auto-rotation timer with animation support
  useEffect(() => {
    if (isActuallyRotating) {
      timerRef.current = setInterval(() => {
        const current = currentSlideRef.current;
        const nextIndex = (current + 1) % slides.length;

        // Trigger animation
        setExitingSlide(current);
        setTransitionDirection('next');
        setCurrentSlide(nextIndex);
        setFocusedIndex(nextIndex);
        onSlideChange?.(nextIndex);

        // Clean up animation state
        animationTimeoutRef.current = setTimeout(() => {
          setExitingSlide(null);
          setTransitionDirection(null);
          animationTimeoutRef.current = null;
        }, 300);
      }, rotationInterval);
    }

    return () => {
      if (timerRef.current) {
        clearInterval(timerRef.current);
        timerRef.current = null;
      }
    };
  }, [isActuallyRotating, slides.length, rotationInterval, onSlideChange]);

  // Cleanup animation timeout and RAF on unmount
  useEffect(() => {
    return () => {
      if (animationTimeoutRef.current) {
        clearTimeout(animationTimeoutRef.current);
        animationTimeoutRef.current = null;
      }
      if (rafRef.current) {
        cancelAnimationFrame(rafRef.current);
        rafRef.current = null;
      }
    };
  }, []);

  // Toggle auto-rotate mode (user intent)
  const toggleAutoRotateMode = useCallback(() => {
    setAutoRotateMode((prev) => {
      const newMode = !prev;
      // When enabling auto-rotate, reset interaction pause so rotation starts immediately
      if (newMode) {
        setIsPausedByInteraction(false);
      }
      return newMode;
    });
  }, []);

  // Pause/resume by interaction (hover/focus)
  const pauseByInteraction = useCallback(() => {
    setIsPausedByInteraction(true);
  }, []);

  const resumeByInteraction = useCallback(() => {
    setIsPausedByInteraction(false);
  }, []);

  // Focus/blur handlers for entire carousel
  const handleCarouselFocusIn = useCallback(() => {
    if (autoRotateMode) {
      pauseByInteraction();
    }
  }, [autoRotateMode, pauseByInteraction]);

  const handleCarouselFocusOut = useCallback(
    (event: React.FocusEvent) => {
      if (!autoRotateMode) return;

      // Only resume if focus is leaving the carousel entirely
      const carousel = event.currentTarget;
      const relatedTarget = event.relatedTarget;
      const focusLeftCarousel =
        relatedTarget === null ||
        (relatedTarget instanceof Node && !carousel.contains(relatedTarget));
      if (focusLeftCarousel) {
        resumeByInteraction();
      }
    },
    [autoRotateMode, resumeByInteraction]
  );

  // Mouse hover handlers for slides container only
  const handleSlidesMouseEnter = useCallback(() => {
    if (autoRotateMode) {
      pauseByInteraction();
    }
  }, [autoRotateMode, pauseByInteraction]);

  const handleSlidesMouseLeave = useCallback(() => {
    if (autoRotateMode) {
      resumeByInteraction();
    }
  }, [autoRotateMode, resumeByInteraction]);

  // Touch/swipe handlers
  const pointerStartX = useRef<number | null>(null);
  const activePointerId = useRef<number | null>(null);
  const rafRef = useRef<number | null>(null);
  const pendingDragOffset = useRef<number>(0);
  const slidesContainerRef = useRef<HTMLDivElement>(null);
  const [dragOffset, setDragOffset] = useState(0);
  const [isDragging, setIsDragging] = useState(false);

  // Compute which adjacent slide to show during drag
  const swipeAdjacentSlide =
    isDragging && dragOffset !== 0
      ? dragOffset > 0
        ? (currentSlide - 1 + slides.length) % slides.length // swiping right, show prev
        : (currentSlide + 1) % slides.length // swiping left, show next
      : null;

  // Instant slide change (no animation) for swipe completion
  const goToSlideInstant = useCallback(
    (index: number) => {
      if (slides.length < 2) return;
      const newIndex = ((index % slides.length) + slides.length) % slides.length;
      setCurrentSlide(newIndex);
      setFocusedIndex(newIndex);
      onSlideChange?.(newIndex);
    },
    [slides.length, onSlideChange]
  );

  const handlePointerDown = useCallback(
    (event: React.PointerEvent<HTMLDivElement>) => {
      if (slides.length < 2) return; // Disable swipe for single slide
      if (activePointerId.current !== null) return; // Ignore if already tracking a pointer
      activePointerId.current = event.pointerId;
      pointerStartX.current = event.clientX;
      setIsDragging(true);
      setDragOffset(0);
      // Capture pointer to receive events even if pointer moves outside element
      event.currentTarget.setPointerCapture(event.pointerId);
      if (autoRotateMode) {
        pauseByInteraction();
      }
    },
    [slides.length, autoRotateMode, pauseByInteraction]
  );

  const handlePointerMove = useCallback((event: React.PointerEvent) => {
    if (activePointerId.current !== event.pointerId) return; // Ignore other pointers
    if (pointerStartX.current === null) return;
    const diff = event.clientX - pointerStartX.current;
    pendingDragOffset.current = diff;

    // Throttle updates using requestAnimationFrame
    if (rafRef.current === null) {
      rafRef.current = requestAnimationFrame(() => {
        setDragOffset(pendingDragOffset.current);
        rafRef.current = null;
      });
    }
  }, []);

  const handlePointerUp = useCallback(
    (event: React.PointerEvent) => {
      if (activePointerId.current !== event.pointerId) return; // Ignore other pointers
      if (pointerStartX.current === null) return;

      const diff = event.clientX - pointerStartX.current;
      const containerWidth = slidesContainerRef.current?.offsetWidth || 300;
      const threshold = containerWidth * 0.2; // 20% of container width

      // Use ref to get current slide value to avoid stale closure
      const current = currentSlideRef.current;
      if (diff > threshold) {
        // Swiped right - go to previous slide (instant, no animation)
        goToSlideInstant(current - 1);
      } else if (diff < -threshold) {
        // Swiped left - go to next slide (instant, no animation)
        goToSlideInstant(current + 1);
      }
      // else: snap back (just reset dragOffset)

      // Cancel any pending RAF
      if (rafRef.current !== null) {
        cancelAnimationFrame(rafRef.current);
        rafRef.current = null;
      }

      activePointerId.current = null;
      pointerStartX.current = null;
      setIsDragging(false);
      setDragOffset(0);

      if (autoRotateMode) {
        resumeByInteraction();
      }
    },
    [goToSlideInstant, autoRotateMode, resumeByInteraction]
  );

  const handlePointerCancel = useCallback((event: React.PointerEvent) => {
    if (activePointerId.current !== event.pointerId) return; // Ignore other pointers
    // Cancel any pending RAF
    if (rafRef.current !== null) {
      cancelAnimationFrame(rafRef.current);
      rafRef.current = null;
    }
    activePointerId.current = null;
    pointerStartX.current = null;
    setIsDragging(false);
    setDragOffset(0);
  }, []);

  const containerClass = `apg-carousel ${className}`.trim();

  return (
    <section
      className={containerClass}
      aria-roledescription="carousel"
      aria-label={ariaLabel}
      data-testid={testId}
      onFocus={handleCarouselFocusIn}
      onBlur={handleCarouselFocusOut}
    >
      {/* Slides Container */}
      <div
        ref={slidesContainerRef}
        id={slidesContainerId}
        data-testid="slides-container"
        className={['apg-carousel-slides', isDragging && 'apg-carousel-slides--dragging']
          .filter(Boolean)
          .join(' ')}
        role="group"
        aria-live={isActuallyRotating ? 'off' : 'polite'}
        aria-atomic="false"
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
        onPointerCancel={handlePointerCancel}
        onMouseEnter={handleSlidesMouseEnter}
        onMouseLeave={handleSlidesMouseLeave}
      >
        {slides.map((slide, index) => {
          const isActive = index === currentSlide;
          const isExiting = index === exitingSlide;
          const isSwipeAdjacent = index === swipeAdjacentSlide;
          const panelId = `${carouselId}-panel-${slide.id}`;
          const tabId = `${carouselId}-tab-${slide.id}`;

          // Determine animation class (only for non-swipe transitions)
          let animationClass = '';
          if (transitionDirection && !isDragging) {
            if (isActive) {
              animationClass = `apg-carousel-slide--entering-${transitionDirection}`;
            } else if (isExiting) {
              animationClass = `apg-carousel-slide--exiting-${transitionDirection}`;
            }
          }

          // Determine swipe position class
          let swipeClass = '';
          if (isSwipeAdjacent && isDragging) {
            swipeClass =
              dragOffset > 0 ? 'apg-carousel-slide--swipe-prev' : 'apg-carousel-slide--swipe-next';
          }

          // Calculate transform for swipe
          let slideStyle: React.CSSProperties | undefined;
          if (isDragging) {
            if (isActive) {
              slideStyle = { transform: `translateX(${dragOffset}px)` };
            } else if (isSwipeAdjacent) {
              // Position adjacent slide next to current slide
              const baseOffset = dragOffset > 0 ? '-100%' : '100%';
              slideStyle = { transform: `translateX(calc(${baseOffset} + ${dragOffset}px))` };
            }
          }

          return (
            <div
              key={slide.id}
              id={panelId}
              role="tabpanel"
              aria-roledescription="slide"
              aria-label={`${index + 1} of ${slides.length}`}
              aria-labelledby={tabId}
              aria-hidden={!isActive}
              inert={!isActive ? true : undefined}
              className={`apg-carousel-slide ${isActive ? 'apg-carousel-slide--active' : ''} ${animationClass} ${swipeClass}`.trim()}
              style={slideStyle}
            >
              {slide.content}
            </div>
          );
        })}
      </div>

      {/* Controls */}
      <div className="apg-carousel-controls">
        {/* Play/Pause Button (first in tab order) */}
        {autoRotate && (
          <button
            type="button"
            className="apg-carousel-play-pause"
            aria-label={autoRotateMode ? 'Stop automatic slide show' : 'Start automatic slide show'}
            onClick={toggleAutoRotateMode}
          >
            {autoRotateMode ? (
              <svg
                aria-hidden="true"
                width="18"
                height="18"
                viewBox="0 0 16 16"
                fill="currentColor"
              >
                <rect x="3" y="2" width="4" height="12" rx="1.5" />
                <rect x="9" y="2" width="4" height="12" rx="1.5" />
              </svg>
            ) : (
              <svg
                aria-hidden="true"
                width="16"
                height="16"
                viewBox="0 0 16 16"
                fill="currentColor"
              >
                <path d="M4 2.5v11a.5.5 0 0 0 .75.43l9-5.5a.5.5 0 0 0 0-.86l-9-5.5A.5.5 0 0 0 4 2.5z" />
              </svg>
            )}
          </button>
        )}

        {/* Tablist (slide indicators) */}
        <div
          ref={tablistRef}
          role="tablist"
          aria-label="Slides"
          className="apg-carousel-tablist"
          onKeyDown={handleKeyDown}
        >
          {slides.map((slide, index) => {
            const isSelected = index === currentSlide;
            const isFocusTarget = index === focusedIndex;
            const tabId = `${carouselId}-tab-${slide.id}`;
            const panelId = `${carouselId}-panel-${slide.id}`;

            return (
              <button
                key={slide.id}
                ref={(el) => {
                  if (el) {
                    tabRefs.current.set(slide.id, el);
                  } else {
                    tabRefs.current.delete(slide.id);
                  }
                }}
                type="button"
                role="tab"
                id={tabId}
                aria-selected={isSelected}
                aria-controls={panelId}
                tabIndex={isFocusTarget ? 0 : -1}
                className={`apg-carousel-tab ${isSelected ? 'apg-carousel-tab--selected' : ''}`}
                onClick={() => goToSlide(index)}
                aria-label={slide.label || `Slide ${index + 1}`}
              >
                <span className="apg-carousel-tab-indicator" aria-hidden="true" />
              </button>
            );
          })}
        </div>

        {/* Previous/Next Buttons */}
        <div role="group" aria-label="Slide controls" className="apg-carousel-nav">
          <button
            type="button"
            className="apg-carousel-prev"
            aria-label="Previous slide"
            aria-controls={slidesContainerId}
            onClick={goToPrevSlide}
          >
            <svg
              aria-hidden="true"
              width="20"
              height="20"
              viewBox="0 0 20 20"
              fill="none"
              stroke="currentColor"
              strokeWidth="2.5"
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              <line x1="15" y1="10" x2="5" y2="10" />
              <polyline points="10 5 5 10 10 15" />
            </svg>
          </button>
          <button
            type="button"
            className="apg-carousel-next"
            aria-label="Next slide"
            aria-controls={slidesContainerId}
            onClick={goToNextSlide}
          >
            <svg
              aria-hidden="true"
              width="20"
              height="20"
              viewBox="0 0 20 20"
              fill="none"
              stroke="currentColor"
              strokeWidth="2.5"
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              <line x1="5" y1="10" x2="15" y2="10" />
              <polyline points="10 5 15 10 10 15" />
            </svg>
          </button>
        </div>
      </div>
    </section>
  );
}

export default Carousel;

Usage

Example
import { Carousel } from './Carousel';

const slides = [
  { id: 'slide1', content: '<p>Slide 1 content</p>', label: 'First slide' },
  { id: 'slide2', content: '<p>Slide 2 content</p>', label: 'Second slide' },
  { id: 'slide3', content: '<p>Slide 3 content</p>', label: 'Third slide' }
];

function App() {
  return (
    <Carousel
      slides={slides}
      aria-label="Featured content"
      autoRotate={true}
      rotationInterval={5000}
      onSlideChange={(index) => console.log('Slide changed:', index)}
    />
  );
}

API

Carousel Props

Prop Type Default Description
slides CarouselSlide[] required Array of slide items
aria-label string required Accessible name for the carousel
initialSlide number 0 Initial slide index (0-based)
autoRotate boolean false Enable auto-rotation
rotationInterval number 5000 Rotation interval in milliseconds
onSlideChange (index: number) => void - Callback when slide changes

CarouselSlide Interface

Types
interface CarouselSlide {
  id: string;
  content: React.ReactNode;
  label?: string;
}

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, auto-rotation behavior, and accessibility requirements. The Carousel component uses a two-layer testing strategy.

Testing Strategy

Unit Tests (Container API)

Verify the component's HTML output using Astro Container API. These tests ensure correct template rendering without requiring a browser.

  • HTML structure and element hierarchy
  • Initial ARIA attributes (aria-roledescription, aria-label, aria-selected)
  • Tablist/tab/tabpanel structure
  • Initial tabindex values (roving tabindex)
  • CSS class application

E2E Tests (Playwright)

Verify Web Component behavior in a real browser environment. These tests cover interactions that require JavaScript execution.

  • Keyboard navigation (Arrow keys, Home, End)
  • Tab selection and slide changes
  • Auto-rotation start/stop
  • Play/pause button interaction
  • Focus management during navigation

Test Categories

High Priority: ARIA Structure (Unit)

Test Description
aria-roledescription="carousel" Container has carousel role description
aria-roledescription="slide" Each tabpanel has slide role description
aria-label (container) Container has accessible name
aria-label="N of M" Each slide has position label (e.g., "1 of 5")

High Priority: Tablist ARIA (Unit)

Test Description
role="tablist" Tab container has tablist role
role="tab" Each slide indicator has tab role
role="tabpanel" Each slide has tabpanel role
aria-selected Active tab has aria-selected="true"
aria-controls Tab references its slide via aria-controls

High Priority: Keyboard Interaction (E2E)

Test Description
ArrowRight Moves focus and activates next slide tab
ArrowLeft Moves focus and activates previous slide tab
Loop navigation Arrow keys loop from last to first and vice versa
Home/End Moves focus to first/last slide tab

High Priority: Focus Management (Unit + E2E)

Test Description
tabIndex=0 (Unit) Selected tab has tabIndex=0 initially
tabIndex=-1 (Unit) Non-selected tabs have tabIndex=-1 initially
Roving tabindex (E2E) Only one tab has tabIndex=0 during navigation

High Priority: Auto-Rotation (Unit + E2E)

Test Description
aria-live="off" (Unit) Initial aria-live when autoRotate is true
aria-live="polite" (Unit) Initial aria-live when autoRotate is false
Play/Pause button (Unit) Button is rendered when autoRotate is true
Play/Pause toggle (E2E) Button toggles rotation state

Medium Priority: Navigation Controls (Unit + E2E)

Test Description
Prev/Next buttons (Unit) Navigation buttons are rendered
aria-controls (Unit) Buttons have aria-controls pointing to slides container
Next button (E2E) Shows next slide on click
Previous button (E2E) Shows previous slide on click
Loop navigation (E2E) Loops from last to first and vice versa

Low Priority: HTML Attributes (Unit)

Test Description
class attribute Custom classes are applied to container
id attribute ID attribute is correctly set

Testing Tools

See testing-strategy.md (opens in new tab) for full documentation.

Carousel.test.tsx
import { render, screen, waitFor, within, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Carousel, type CarouselSlide } from './Carousel';

// Mock matchMedia for tests
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

// Test slide data
const defaultSlides: CarouselSlide[] = [
  { id: 'slide1', content: <div>Slide 1 Content</div>, label: 'Slide 1' },
  { id: 'slide2', content: <div>Slide 2 Content</div>, label: 'Slide 2' },
  { id: 'slide3', content: <div>Slide 3 Content</div>, label: 'Slide 3' },
];

const fiveSlides: CarouselSlide[] = [
  { id: 'slide1', content: <div>Slide 1</div>, label: 'Slide 1' },
  { id: 'slide2', content: <div>Slide 2</div>, label: 'Slide 2' },
  { id: 'slide3', content: <div>Slide 3</div>, label: 'Slide 3' },
  { id: 'slide4', content: <div>Slide 4</div>, label: 'Slide 4' },
  { id: 'slide5', content: <div>Slide 5</div>, label: 'Slide 5' },
];

describe('Carousel', () => {
  beforeEach(() => {
    vi.useFakeTimers({ shouldAdvanceTime: true });
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  // 🔴 High Priority: APG ARIA Structure
  describe('APG: ARIA Structure', () => {
    it('has aria-roledescription="carousel" on container', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
      const carousel = screen.getByRole('region');
      expect(carousel).toHaveAttribute('aria-roledescription', 'carousel');
    });

    it('has aria-label on container', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
      const carousel = screen.getByRole('region');
      expect(carousel).toHaveAttribute('aria-label', 'Featured content');
    });

    it('has aria-roledescription="slide" on each tabpanel', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
      const panels = screen.getAllByRole('tabpanel', { hidden: true });
      panels.forEach((panel) => {
        expect(panel).toHaveAttribute('aria-roledescription', 'slide');
      });
    });

    it('has aria-label="N of M" on each slide', () => {
      render(<Carousel slides={fiveSlides} aria-label="Featured content" />);
      const panels = screen.getAllByRole('tabpanel', { hidden: true });

      expect(panels[0]).toHaveAttribute('aria-label', '1 of 5');
      expect(panels[1]).toHaveAttribute('aria-label', '2 of 5');
      expect(panels[2]).toHaveAttribute('aria-label', '3 of 5');
      expect(panels[3]).toHaveAttribute('aria-label', '4 of 5');
      expect(panels[4]).toHaveAttribute('aria-label', '5 of 5');
    });
  });

  describe('APG: Tablist ARIA', () => {
    it('has role="tablist" on tab container', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
      expect(screen.getByRole('tablist')).toBeInTheDocument();
    });

    it('has role="tab" on each tab button', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
      const tabs = screen.getAllByRole('tab');
      expect(tabs).toHaveLength(3);
    });

    it('has role="tabpanel" on each slide', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
      const panels = screen.getAllByRole('tabpanel', { hidden: true });
      expect(panels).toHaveLength(3);
    });

    it('has aria-selected="true" on active tab', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
      const tabs = screen.getAllByRole('tab');

      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
      expect(tabs[1]).toHaveAttribute('aria-selected', 'false');
      expect(tabs[2]).toHaveAttribute('aria-selected', 'false');
    });

    it('has aria-controls pointing to tabpanel', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
      const tabs = screen.getAllByRole('tab');
      const panels = screen.getAllByRole('tabpanel', { hidden: true });

      tabs.forEach((tab, index) => {
        expect(tab).toHaveAttribute('aria-controls', panels[index].id);
      });
    });

    it('panel aria-labelledby matches tab id', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
      const tabs = screen.getAllByRole('tab');
      const panels = screen.getAllByRole('tabpanel', { hidden: true });

      panels.forEach((panel, index) => {
        expect(panel).toHaveAttribute('aria-labelledby', tabs[index].id);
      });
    });
  });

  // 🔴 High Priority: Keyboard Interaction
  describe('APG: Keyboard Interaction', () => {
    it('moves focus to next tab on ArrowRight', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[0]);
      await user.keyboard('{ArrowRight}');

      expect(tabs[1]).toHaveFocus();
      expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
    });

    it('moves focus to previous tab on ArrowLeft', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" initialSlide={1} />);

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[1]);
      await user.keyboard('{ArrowLeft}');

      expect(tabs[0]).toHaveFocus();
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
    });

    it('wraps from last to first on ArrowRight', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" initialSlide={2} />);

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[2]);
      await user.keyboard('{ArrowRight}');

      expect(tabs[0]).toHaveFocus();
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
    });

    it('wraps from first to last on ArrowLeft', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[0]);
      await user.keyboard('{ArrowLeft}');

      expect(tabs[2]).toHaveFocus();
      expect(tabs[2]).toHaveAttribute('aria-selected', 'true');
    });

    it('moves focus to first tab on Home', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" initialSlide={2} />);

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[2]);
      await user.keyboard('{Home}');

      expect(tabs[0]).toHaveFocus();
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
    });

    it('moves focus to last tab on End', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[0]);
      await user.keyboard('{End}');

      expect(tabs[2]).toHaveFocus();
      expect(tabs[2]).toHaveAttribute('aria-selected', 'true');
    });

    it('activates tab on Enter', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[0]);
      await user.keyboard('{ArrowRight}');
      await user.keyboard('{Enter}');

      expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
    });

    it('activates tab on Space', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[0]);
      await user.keyboard('{ArrowRight}');
      await user.keyboard(' ');

      expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
    });
  });

  // 🔴 High Priority: Auto-Rotation
  describe('APG: Auto-Rotation', () => {
    it('rotates slides automatically when enabled', async () => {
      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          autoRotate
          rotationInterval={3000}
        />
      );

      const tabs = screen.getAllByRole('tab');
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');

      // Advance timer
      act(() => {
        vi.advanceTimersByTime(3000);
      });

      await waitFor(() => {
        expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
      });

      act(() => {
        vi.advanceTimersByTime(3000);
      });

      await waitFor(() => {
        expect(tabs[2]).toHaveAttribute('aria-selected', 'true');
      });
    });

    it('has aria-live="off" during auto-rotation', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" autoRotate />);

      const slidesContainer = screen.getByTestId('slides-container');
      expect(slidesContainer).toHaveAttribute('aria-live', 'off');
    });

    it('has aria-live="polite" when rotation stopped', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" autoRotate={false} />);

      const slidesContainer = screen.getByTestId('slides-container');
      expect(slidesContainer).toHaveAttribute('aria-live', 'polite');
    });

    it('stops rotation on keyboard focus', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          autoRotate
          rotationInterval={3000}
        />
      );

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[0]);

      const slidesContainer = screen.getByTestId('slides-container');
      expect(slidesContainer).toHaveAttribute('aria-live', 'polite');

      // Advance time - should NOT rotate
      act(() => {
        vi.advanceTimersByTime(3000);
      });

      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
    });

    it('stops rotation on mouse hover over slides container', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          autoRotate
          rotationInterval={3000}
        />
      );

      const slidesContainer = screen.getByTestId('slides-container');
      await user.hover(slidesContainer);

      expect(slidesContainer).toHaveAttribute('aria-live', 'polite');

      // Advance time - should NOT rotate
      act(() => {
        vi.advanceTimersByTime(3000);
      });

      const tabs = screen.getAllByRole('tab');
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
    });

    it('resumes rotation on focus/hover out (if not manually stopped)', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          autoRotate
          rotationInterval={3000}
        />
      );

      const slidesContainer = screen.getByTestId('slides-container');
      const tabs = screen.getAllByRole('tab');

      // Hover over slides container to pause
      await user.hover(slidesContainer);

      act(() => {
        vi.advanceTimersByTime(3000);
      });
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');

      // Unhover to resume
      await user.unhover(slidesContainer);

      act(() => {
        vi.advanceTimersByTime(3000);
      });

      await waitFor(() => {
        expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
      });
    });

    it('toggles rotation with play/pause button', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          autoRotate
          rotationInterval={3000}
        />
      );

      const playPauseButton = screen.getByRole('button', { name: /stop|pause/i });

      // Click to pause
      await user.click(playPauseButton);

      const slidesContainer = screen.getByTestId('slides-container');
      expect(slidesContainer).toHaveAttribute('aria-live', 'polite');

      const tabs = screen.getAllByRole('tab');

      act(() => {
        vi.advanceTimersByTime(3000);
      });
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');

      // Click to resume
      const startButton = screen.getByRole('button', { name: /start|play/i });
      await user.click(startButton);

      act(() => {
        vi.advanceTimersByTime(3000);
      });

      await waitFor(() => {
        expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
      });
    });

    it('updates button label based on rotation state', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          autoRotate
          rotationInterval={3000}
        />
      );

      // Initially rotating - button should say "Stop" or "Pause"
      expect(screen.getByRole('button', { name: /stop|pause/i })).toBeInTheDocument();

      // Click to pause
      await user.click(screen.getByRole('button', { name: /stop|pause/i }));

      // Button should now say "Start" or "Play"
      expect(screen.getByRole('button', { name: /start|play/i })).toBeInTheDocument();
    });

    it('respects prefers-reduced-motion', () => {
      // Mock matchMedia for prefers-reduced-motion
      const originalMatchMedia = window.matchMedia;
      window.matchMedia = vi.fn().mockImplementation((query) => ({
        matches: query === '(prefers-reduced-motion: reduce)',
        media: query,
        onchange: null,
        addListener: vi.fn(),
        removeListener: vi.fn(),
        addEventListener: vi.fn(),
        removeEventListener: vi.fn(),
        dispatchEvent: vi.fn(),
      }));

      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          autoRotate
          rotationInterval={3000}
        />
      );

      // Should not auto-rotate when reduced motion is preferred
      const slidesContainer = screen.getByTestId('slides-container');
      expect(slidesContainer).toHaveAttribute('aria-live', 'polite');

      const tabs = screen.getAllByRole('tab');

      act(() => {
        vi.advanceTimersByTime(3000);
      });

      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');

      window.matchMedia = originalMatchMedia;
    });

    it('loops back to first slide after last', async () => {
      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          autoRotate
          rotationInterval={1000}
          initialSlide={2}
        />
      );

      const tabs = screen.getAllByRole('tab');
      expect(tabs[2]).toHaveAttribute('aria-selected', 'true');

      act(() => {
        vi.advanceTimersByTime(1000);
      });

      await waitFor(() => {
        expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
      });
    });
  });

  // 🔴 High Priority: Focus Management
  describe('APG: Focus Management', () => {
    it('uses roving tabindex on tablist', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);
      const tabs = screen.getAllByRole('tab');

      expect(tabs[0]).toHaveAttribute('tabIndex', '0');
      expect(tabs[1]).toHaveAttribute('tabIndex', '-1');
      expect(tabs[2]).toHaveAttribute('tabIndex', '-1');
    });

    it('only one tab has tabindex="0" at a time', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[0]);
      await user.keyboard('{ArrowRight}');

      const tabsWithZeroTabindex = tabs.filter((tab) => tab.getAttribute('tabIndex') === '0');
      expect(tabsWithZeroTabindex).toHaveLength(1);
    });

    it('rotation control is first in tab order', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" autoRotate />);

      // Start from outside the carousel and tab in
      const carousel = screen.getByRole('region');
      carousel.focus();

      await user.tab();

      // First focusable should be the play/pause button
      const playPauseButton = screen.getByRole('button', { name: /stop|pause|start|play/i });
      expect(playPauseButton).toHaveFocus();
    });
  });

  // 🟡 Medium Priority: Navigation Controls
  describe('Navigation Controls', () => {
    it('shows next slide on next button click', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);

      const nextButton = screen.getByRole('button', { name: /next/i });
      await user.click(nextButton);

      const tabs = screen.getAllByRole('tab');
      expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
    });

    it('shows previous slide on previous button click', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" initialSlide={1} />);

      const prevButton = screen.getByRole('button', { name: /previous|prev/i });
      await user.click(prevButton);

      const tabs = screen.getAllByRole('tab');
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
    });

    it('wraps to first slide from last on next', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" initialSlide={2} />);

      const nextButton = screen.getByRole('button', { name: /next/i });
      await user.click(nextButton);

      const tabs = screen.getAllByRole('tab');
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
    });

    it('wraps to last slide from first on previous', async () => {
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);

      const prevButton = screen.getByRole('button', { name: /previous|prev/i });
      await user.click(prevButton);

      const tabs = screen.getAllByRole('tab');
      expect(tabs[2]).toHaveAttribute('aria-selected', 'true');
    });

    it('prev/next buttons have aria-controls', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" />);

      const prevButton = screen.getByRole('button', { name: /previous|prev/i });
      const nextButton = screen.getByRole('button', { name: /next/i });
      const slidesContainer = screen.getByTestId('slides-container');

      expect(prevButton).toHaveAttribute('aria-controls', slidesContainer.id);
      expect(nextButton).toHaveAttribute('aria-controls', slidesContainer.id);
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const { container } = render(
        <Carousel slides={defaultSlides} aria-label="Featured content" />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with autoRotate', async () => {
      const { container } = render(
        <Carousel slides={defaultSlides} aria-label="Featured content" autoRotate />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟢 Low Priority: Props & Behavior
  describe('Props & Behavior', () => {
    it('calls onSlideChange when slide changes', async () => {
      const handleSlideChange = vi.fn();
      const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
      render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          onSlideChange={handleSlideChange}
        />
      );

      const tabs = screen.getAllByRole('tab');
      await user.click(tabs[1]);

      expect(handleSlideChange).toHaveBeenCalledWith(1);
    });

    it('respects initialSlide prop', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" initialSlide={1} />);

      const tabs = screen.getAllByRole('tab');
      expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
    });

    it('respects autoRotate prop', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" autoRotate={false} />);

      const slidesContainer = screen.getByTestId('slides-container');
      expect(slidesContainer).toHaveAttribute('aria-live', 'polite');
    });

    it('applies className to container', () => {
      const { container } = render(
        <Carousel
          slides={defaultSlides}
          aria-label="Featured content"
          className="custom-carousel"
        />
      );
      const carousel = container.firstChild as HTMLElement;
      expect(carousel).toHaveClass('custom-carousel');
    });
  });

  // Edge Cases
  describe('Edge Cases', () => {
    it('handles single slide', () => {
      const singleSlide: CarouselSlide[] = [
        { id: 'slide1', content: <div>Only Slide</div>, label: 'Only Slide' },
      ];
      render(<Carousel slides={singleSlide} aria-label="Featured content" />);

      const tabs = screen.getAllByRole('tab');
      expect(tabs).toHaveLength(1);

      const panel = screen.getByRole('tabpanel');
      expect(panel).toHaveAttribute('aria-label', '1 of 1');
    });

    it('handles initialSlide out of bounds', () => {
      render(<Carousel slides={defaultSlides} aria-label="Featured content" initialSlide={99} />);

      const tabs = screen.getAllByRole('tab');
      // Should fallback to first slide
      expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
    });
  });
});

Resources