APG Patterns
日本語
日本語

Carousel

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

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.

Open demo only →

Accessibility Features

WAI-ARIA Roles

RoleTarget ElementDescription
regionContainer (section)Landmark region for the carousel
groupSlides containerGroups all slides together
tablistTab containerContainer for slide indicator tabs
tabEach tab buttonIndividual slide indicator
tabpanelEach slideIndividual slide content area

WAI-ARIA Properties

aria-roledescription

Announces “carousel” to screen readers

Values
carousel
Required
Yes

aria-roledescription

Announces “slide” instead of “tabpanel”

Values
slide
Required
Yes

aria-label

Describes the carousel purpose

Values
Text
Required
Yes

aria-label

Slide position (e.g., “1 of 5”)

Values
N of M
Required
Yes

aria-controls

References controlled element

Values
ID reference
Required
Yes

aria-labelledby

References associated tab

Values
ID reference
Required
Yes

aria-atomic

Only announce changed content

Values
false
Required
No

WAI-ARIA States

aria-selected

Target Element
Tab element
Values
true | false
Required
Yes
Change Trigger

Tab click, Arrow keys, Prev/Next buttons, Auto-rotation

aria-live

Target Element
Slides container
Values
off | polite
Required
Yes
Change Trigger

Play/Pause click, Focus in/out, Mouse hover

Keyboard Support

KeyAction
TabNavigate between controls (Play/Pause, tablist, Prev/Next)
ArrowRightMove to next slide indicator tab (loops to first)
ArrowLeftMove to previous slide indicator tab (loops to last)
HomeMove focus to first slide indicator tab
EndMove focus to last slide indicator tab
Enter / SpaceActivate focused tab or button
  • Set aria-live to “off” during auto-rotation to prevent interrupting users. Changes to “polite” when rotation stops, allowing slide changes to be announced.

Focus Management

EventBehavior
Selected tabtabIndex="0"
Other tabstabIndex="-1"
Keyboard focus enters carouselRotation pauses temporarily, aria-live changes to “polite”
Keyboard focus leaves carouselRotation resumes (if auto-rotate mode is on)
Mouse hovers over slidesRotation pauses temporarily
Mouse leaves slidesRotation resumes (if auto-rotate mode is on)
Pause button clickedTurns off auto-rotate mode, button shows play icon
Play button clickedTurns on auto-rotate mode and starts rotation immediately
prefers-reduced-motion: reduceAuto-rotation disabled by default

References

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) */}
        {/* eslint-disable-next-line jsx-a11y/interactive-supports-focus -- keydown handled on container; child buttons are focusable */}
        <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

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

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

Running Tests

# Run unit tests for Carousel
npm run test -- carousel

# Run E2E tests for Carousel (all frameworks)
npm run test:e2e:pattern --pattern=carousel

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