Carousel
A rotating set of content items (slides) displayed one at a time with controls to navigate between them.
🤖 AI Implementation GuideDemo
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
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
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
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
- Vitest (opens in new tab) - Test runner for unit tests with fake timers for auto-rotation testing
- Astro Container API (opens in new tab) - Server-side component rendering for unit tests
- Playwright (opens in new tab) - Browser automation for E2E tests
- Testing Library (opens in new tab) - Framework-specific testing utilities (React, Vue, Svelte)
- jest-axe (opens in new tab) - Automated accessibility testing
See testing-strategy.md (opens in new tab) for full documentation.
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
- WAI-ARIA APG: Carousel Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist