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
---
/**
* APG Carousel Pattern - Astro Implementation
*
* A carousel displays a set of slides, one at a time, with controls to navigate
* between them and optionally auto-rotate.
* Uses Web Components for client-side keyboard navigation and state management.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/carousel/
*/
export interface CarouselSlide {
id: string;
/** Slide content (HTML string) */
content: string;
label?: string;
}
export interface Props {
/** 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;
/** Additional CSS class */
class?: string;
/** Instance ID (optional, auto-generated if not provided) */
id?: string;
/** Test ID for E2E testing */
'data-testid'?: string;
}
const {
slides,
'aria-label': ariaLabel,
initialSlide = 0,
autoRotate = false,
rotationInterval = 5000,
class: className = '',
id,
'data-testid': testId,
} = Astro.props;
// Validate initialSlide - fallback to 0 if out of bounds
const validInitialSlide = initialSlide >= 0 && initialSlide < slides.length ? initialSlide : 0;
// Generate unique ID for this instance
const instanceId = id || `carousel-${Math.random().toString(36).substring(2, 11)}`;
const slidesContainerId = `${instanceId}-slides`;
const containerClass = `apg-carousel ${className}`.trim();
---
<apg-carousel
data-auto-rotate={autoRotate ? 'true' : 'false'}
data-rotation-interval={rotationInterval.toString()}
data-initial-slide={validInitialSlide.toString()}
>
<section
class={containerClass}
aria-roledescription="carousel"
aria-label={ariaLabel}
id={id}
data-testid={testId}
>
<!-- Slides Container -->
<div
id={slidesContainerId}
data-testid="slides-container"
class="apg-carousel-slides"
role="group"
aria-live={autoRotate ? 'off' : 'polite'}
aria-atomic="false"
>
{
slides.map((slide, index) => {
const isActive = index === validInitialSlide;
const panelId = `${instanceId}-panel-${slide.id}`;
const tabId = `${instanceId}-tab-${slide.id}`;
return (
<div
id={panelId}
role="tabpanel"
aria-roledescription="slide"
aria-label={`${index + 1} of ${slides.length}`}
aria-labelledby={tabId}
aria-hidden={!isActive}
inert={!isActive ? true : undefined}
class={`apg-carousel-slide ${isActive ? 'apg-carousel-slide--active' : ''}`}
data-slide-id={slide.id}
data-slide-index={index.toString()}
>
<Fragment set:html={slide.content} />
</div>
);
})
}
</div>
<!-- Controls -->
<div class="apg-carousel-controls">
<!-- Play/Pause Button (first in tab order) -->
{
autoRotate && (
<button
type="button"
class="apg-carousel-play-pause"
aria-label="Stop automatic slide show"
data-playing="true"
>
<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>
</button>
)
}
<!-- Tablist (slide indicators) -->
<div role="tablist" aria-label="Slides" class="apg-carousel-tablist">
{
slides.map((slide, index) => {
const isSelected = index === validInitialSlide;
const tabId = `${instanceId}-tab-${slide.id}`;
const panelId = `${instanceId}-panel-${slide.id}`;
return (
<button
type="button"
role="tab"
id={tabId}
aria-selected={isSelected ? 'true' : 'false'}
aria-controls={panelId}
tabindex={isSelected ? 0 : -1}
class={`apg-carousel-tab ${isSelected ? 'apg-carousel-tab--selected' : ''}`}
aria-label={slide.label || `Slide ${index + 1}`}
data-tab-index={index.toString()}
>
<span class="apg-carousel-tab-indicator" aria-hidden="true" />
</button>
);
})
}
</div>
<!-- Previous/Next Buttons -->
<div role="group" aria-label="Slide controls" class="apg-carousel-nav">
<button
type="button"
class="apg-carousel-prev"
aria-label="Previous slide"
aria-controls={slidesContainerId}
>
<svg
aria-hidden="true"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="15" y1="10" x2="5" y2="10"></line>
<polyline points="10 5 5 10 10 15"></polyline>
</svg>
</button>
<button
type="button"
class="apg-carousel-next"
aria-label="Next slide"
aria-controls={slidesContainerId}
>
<svg
aria-hidden="true"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="5" y1="10" x2="15" y2="10"></line>
<polyline points="10 5 15 10 10 15"></polyline>
</svg>
</button>
</div>
</div>
</section>
<script>
class ApgCarousel extends HTMLElement {
private currentSlide = 0;
private focusedIndex = 0;
// New state model: separate user intent from temporary pause
private autoRotateMode = false;
private isPausedByInteraction = false;
private timerRef: ReturnType<typeof setInterval> | null = null;
private animationTimeoutRef: ReturnType<typeof setTimeout> | null = null;
private rafRef: number | null = null;
private pendingDragOffset = 0;
private pointerStartX: number | null = null;
private activePointerId: number | null = null;
private isDragging = false;
private dragOffset = 0;
private slides: HTMLElement[] = [];
private tabs: HTMLButtonElement[] = [];
private tablist: HTMLElement | null = null;
private slidesContainer: HTMLElement | null = null;
private playPauseButton: HTMLButtonElement | null = null;
private prevButton: HTMLButtonElement | null = null;
private nextButton: HTMLButtonElement | null = null;
// Bound event handlers (stored for cleanup)
private boundHandleKeyDown: (e: KeyboardEvent) => void;
private boundToggleAutoRotateMode: () => void;
private boundGoToPrevSlide: () => void;
private boundGoToNextSlide: () => void;
private boundHandleCarouselFocusIn: () => void;
private boundHandleCarouselFocusOut: (e: FocusEvent) => void;
private boundHandleSlidesMouseEnter: () => void;
private boundHandleSlidesMouseLeave: () => void;
private boundHandlePointerDown: (e: PointerEvent) => void;
private boundHandlePointerMove: (e: PointerEvent) => void;
private boundHandlePointerUp: (e: PointerEvent) => void;
private boundHandlePointerCancel: (e: PointerEvent) => void;
private tabClickHandlers: Array<() => void> = [];
constructor() {
super();
// Bind handlers once in constructor
this.boundHandleKeyDown = this.handleKeyDown.bind(this);
this.boundToggleAutoRotateMode = this.toggleAutoRotateMode.bind(this);
this.boundGoToPrevSlide = this.goToPrevSlide.bind(this);
this.boundGoToNextSlide = this.goToNextSlide.bind(this);
this.boundHandleCarouselFocusIn = this.handleCarouselFocusIn.bind(this);
this.boundHandleCarouselFocusOut = this.handleCarouselFocusOut.bind(this);
this.boundHandleSlidesMouseEnter = this.handleSlidesMouseEnter.bind(this);
this.boundHandleSlidesMouseLeave = this.handleSlidesMouseLeave.bind(this);
this.boundHandlePointerDown = this.handlePointerDown.bind(this);
this.boundHandlePointerMove = this.handlePointerMove.bind(this);
this.boundHandlePointerUp = this.handlePointerUp.bind(this);
this.boundHandlePointerCancel = this.handlePointerCancel.bind(this);
}
connectedCallback() {
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
this.initializeElements();
this.initializeState();
this.setupEventListeners();
this.startAutoRotation();
});
}
disconnectedCallback() {
this.stopAutoRotation();
if (this.animationTimeoutRef) {
clearTimeout(this.animationTimeoutRef);
this.animationTimeoutRef = null;
}
if (this.rafRef) {
cancelAnimationFrame(this.rafRef);
this.rafRef = null;
}
this.removeEventListeners();
}
private initializeElements() {
this.slides = Array.from(this.querySelectorAll('[role="tabpanel"]'));
this.tabs = Array.from(this.querySelectorAll('[role="tab"]'));
this.tablist = this.querySelector('[role="tablist"]');
this.slidesContainer = this.querySelector('[data-testid="slides-container"]');
this.playPauseButton = this.querySelector('.apg-carousel-play-pause');
this.prevButton = this.querySelector('.apg-carousel-prev');
this.nextButton = this.querySelector('.apg-carousel-next');
}
private initializeState() {
const initialSlide = parseInt(this.dataset.initialSlide || '0', 10);
this.currentSlide = initialSlide;
this.focusedIndex = initialSlide;
const autoRotate = this.dataset.autoRotate === 'true';
// Check prefers-reduced-motion
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
if (prefersReducedMotion) {
this.autoRotateMode = false;
} else {
this.autoRotateMode = autoRotate;
}
} else {
this.autoRotateMode = autoRotate;
}
this.updateAriaLive();
// Sync play/pause button state with autoRotateMode (important for prefers-reduced-motion)
this.updatePlayPauseButton();
}
private setupEventListeners() {
// Tablist keyboard navigation
this.tablist?.addEventListener('keydown', this.boundHandleKeyDown);
// Tab clicks
this.tabClickHandlers = [];
this.tabs.forEach((tab, index) => {
const handler = () => this.goToSlide(index);
this.tabClickHandlers.push(handler);
tab.addEventListener('click', handler);
});
// Play/Pause button
this.playPauseButton?.addEventListener('click', this.boundToggleAutoRotateMode);
// Previous/Next buttons
this.prevButton?.addEventListener('click', this.boundGoToPrevSlide);
this.nextButton?.addEventListener('click', this.boundGoToNextSlide);
// Focus/blur for auto-rotation pause (on entire carousel)
this.addEventListener('focusin', this.boundHandleCarouselFocusIn);
this.addEventListener('focusout', this.boundHandleCarouselFocusOut);
// Mouse hover for auto-rotation pause (only on slides container)
this.slidesContainer?.addEventListener('mouseenter', this.boundHandleSlidesMouseEnter);
this.slidesContainer?.addEventListener('mouseleave', this.boundHandleSlidesMouseLeave);
// Touch/swipe
this.slidesContainer?.addEventListener('pointerdown', this.boundHandlePointerDown);
this.slidesContainer?.addEventListener('pointermove', this.boundHandlePointerMove);
this.slidesContainer?.addEventListener('pointerup', this.boundHandlePointerUp);
this.slidesContainer?.addEventListener('pointercancel', this.boundHandlePointerCancel);
}
private removeEventListeners() {
// Tablist keyboard navigation
this.tablist?.removeEventListener('keydown', this.boundHandleKeyDown);
// Tab clicks
this.tabs.forEach((tab, index) => {
const handler = this.tabClickHandlers[index];
if (handler) {
tab.removeEventListener('click', handler);
}
});
this.tabClickHandlers = [];
// Play/Pause button
this.playPauseButton?.removeEventListener('click', this.boundToggleAutoRotateMode);
// Previous/Next buttons
this.prevButton?.removeEventListener('click', this.boundGoToPrevSlide);
this.nextButton?.removeEventListener('click', this.boundGoToNextSlide);
// Focus/blur for auto-rotation pause (on entire carousel)
this.removeEventListener('focusin', this.boundHandleCarouselFocusIn);
this.removeEventListener('focusout', this.boundHandleCarouselFocusOut);
// Mouse hover for auto-rotation pause (only on slides container)
this.slidesContainer?.removeEventListener('mouseenter', this.boundHandleSlidesMouseEnter);
this.slidesContainer?.removeEventListener('mouseleave', this.boundHandleSlidesMouseLeave);
// Touch/swipe
this.slidesContainer?.removeEventListener('pointerdown', this.boundHandlePointerDown);
this.slidesContainer?.removeEventListener('pointermove', this.boundHandlePointerMove);
this.slidesContainer?.removeEventListener('pointerup', this.boundHandlePointerUp);
this.slidesContainer?.removeEventListener('pointercancel', this.boundHandlePointerCancel);
}
private handleKeyDown(event: KeyboardEvent) {
const { key } = event;
let newIndex = this.focusedIndex;
let shouldPreventDefault = false;
switch (key) {
case 'ArrowRight':
newIndex = (this.focusedIndex + 1) % this.slides.length;
shouldPreventDefault = true;
break;
case 'ArrowLeft':
newIndex = (this.focusedIndex - 1 + this.slides.length) % this.slides.length;
shouldPreventDefault = true;
break;
case 'Home':
newIndex = 0;
shouldPreventDefault = true;
break;
case 'End':
newIndex = this.slides.length - 1;
shouldPreventDefault = true;
break;
case 'Enter':
case ' ':
this.goToSlide(this.focusedIndex);
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== this.focusedIndex) {
this.focusedIndex = newIndex;
this.tabs[newIndex]?.focus();
this.goToSlide(newIndex);
}
}
}
private goToSlide(index: number) {
if (this.slides.length < 2) return;
const newIndex = ((index % this.slides.length) + this.slides.length) % this.slides.length;
if (newIndex === this.currentSlide) return;
const previousSlide = this.currentSlide;
// Determine direction based on index change
const isWrapForward = previousSlide === this.slides.length - 1 && newIndex === 0;
const isWrapBackward = previousSlide === 0 && newIndex === this.slides.length - 1;
const direction =
isWrapForward || (!isWrapBackward && newIndex > previousSlide) ? 'next' : 'prev';
this.currentSlide = newIndex;
this.focusedIndex = newIndex;
// Update slides with animation classes
this.slides.forEach((slide, i) => {
const isActive = i === newIndex;
const isExiting = i === previousSlide;
// Only active slide is exposed to AT, exiting is visual-only during animation
slide.setAttribute('aria-hidden', (!isActive).toString());
if (isActive) {
slide.removeAttribute('inert');
} else {
slide.setAttribute('inert', '');
}
slide.classList.toggle('apg-carousel-slide--active', isActive);
// Remove old animation classes
slide.classList.remove(
'apg-carousel-slide--entering-next',
'apg-carousel-slide--entering-prev',
'apg-carousel-slide--exiting-next',
'apg-carousel-slide--exiting-prev',
'apg-carousel-slide--swipe-prev',
'apg-carousel-slide--swipe-next'
);
// Add new animation classes
if (isActive) {
slide.classList.add(`apg-carousel-slide--entering-${direction}`);
} else if (isExiting) {
slide.classList.add(`apg-carousel-slide--exiting-${direction}`);
}
});
// Update tabs
this.tabs.forEach((tab, i) => {
const isSelected = i === newIndex;
const isFocusTarget = i === this.focusedIndex;
tab.setAttribute('aria-selected', isSelected.toString());
tab.tabIndex = isFocusTarget ? 0 : -1;
tab.classList.toggle('apg-carousel-tab--selected', isSelected);
});
// Dispatch event
this.dispatchEvent(new CustomEvent('slidechange', { detail: { index: newIndex } }));
// Clean up after animation
this.animationTimeoutRef = setTimeout(() => {
this.animationTimeoutRef = null;
// Hide exiting slide and remove animation classes
this.slides.forEach((slide, i) => {
const shouldHide = i !== this.currentSlide;
slide.setAttribute('aria-hidden', shouldHide.toString());
if (shouldHide) {
slide.setAttribute('inert', '');
} else {
slide.removeAttribute('inert');
}
slide.classList.remove(
'apg-carousel-slide--entering-next',
'apg-carousel-slide--entering-prev',
'apg-carousel-slide--exiting-next',
'apg-carousel-slide--exiting-prev'
);
});
}, 300);
}
// Instant slide change (no animation) for swipe completion
private goToSlideInstant(index: number) {
if (this.slides.length < 2) return;
const newIndex = ((index % this.slides.length) + this.slides.length) % this.slides.length;
this.currentSlide = newIndex;
this.focusedIndex = newIndex;
// Update slides without animation
this.slides.forEach((slide, i) => {
const isActive = i === newIndex;
slide.setAttribute('aria-hidden', (!isActive).toString());
if (isActive) {
slide.removeAttribute('inert');
} else {
slide.setAttribute('inert', '');
}
slide.classList.toggle('apg-carousel-slide--active', isActive);
// Remove all animation and swipe classes
slide.classList.remove(
'apg-carousel-slide--entering-next',
'apg-carousel-slide--entering-prev',
'apg-carousel-slide--exiting-next',
'apg-carousel-slide--exiting-prev',
'apg-carousel-slide--swipe-prev',
'apg-carousel-slide--swipe-next'
);
slide.style.transform = '';
});
// Update tabs
this.tabs.forEach((tab, i) => {
const isSelected = i === newIndex;
const isFocusTarget = i === this.focusedIndex;
tab.setAttribute('aria-selected', isSelected.toString());
tab.tabIndex = isFocusTarget ? 0 : -1;
tab.classList.toggle('apg-carousel-tab--selected', isSelected);
});
// Dispatch event
this.dispatchEvent(new CustomEvent('slidechange', { detail: { index: newIndex } }));
}
private goToNextSlide() {
this.goToSlide(this.currentSlide + 1);
}
private goToPrevSlide() {
this.goToSlide(this.currentSlide - 1);
}
// Computed: actual rotation state
private get isActuallyRotating() {
return this.autoRotateMode && !this.isPausedByInteraction;
}
private get rotationInterval() {
return parseInt(this.dataset.rotationInterval || '5000', 10);
}
private startAutoRotation() {
this.stopAutoRotation();
if (this.isActuallyRotating) {
this.timerRef = setInterval(() => {
this.goToSlide(this.currentSlide + 1);
}, this.rotationInterval);
}
}
private stopAutoRotation() {
if (this.timerRef) {
clearInterval(this.timerRef);
this.timerRef = null;
}
}
private updateAriaLive() {
if (this.slidesContainer) {
this.slidesContainer.setAttribute(
'aria-live',
this.isActuallyRotating ? 'off' : 'polite'
);
}
}
private updatePlayPauseButton() {
if (this.playPauseButton) {
// Button reflects autoRotateMode (user intent), not isActuallyRotating
this.playPauseButton.setAttribute(
'aria-label',
this.autoRotateMode ? 'Stop automatic slide show' : 'Start automatic slide show'
);
this.playPauseButton.innerHTML = this.autoRotateMode
? '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="2" width="4" height="12" rx="1" /><rect x="9" y="2" width="4" height="12" rx="1" /></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>';
this.playPauseButton.dataset.playing = this.autoRotateMode.toString();
}
}
// Toggle auto-rotate mode (user intent)
private toggleAutoRotateMode() {
this.autoRotateMode = !this.autoRotateMode;
// When enabling auto-rotate, reset interaction pause so rotation starts immediately
if (this.autoRotateMode) {
this.isPausedByInteraction = false;
}
this.startAutoRotation();
this.updateAriaLive();
this.updatePlayPauseButton();
}
// Pause/resume by interaction (hover/focus)
private pauseByInteraction() {
this.isPausedByInteraction = true;
this.stopAutoRotation();
this.updateAriaLive();
}
private resumeByInteraction() {
this.isPausedByInteraction = false;
this.startAutoRotation();
this.updateAriaLive();
}
// Focus/blur handlers for entire carousel
private handleCarouselFocusIn() {
if (this.autoRotateMode) {
this.pauseByInteraction();
}
}
private handleCarouselFocusOut(event: FocusEvent) {
if (!this.autoRotateMode) {
return;
}
// Only resume if focus is leaving the carousel entirely
const { relatedTarget } = event;
// Treat null relatedTarget (focus moved to body) as "left carousel"
const focusLeftCarousel =
relatedTarget === null ||
(relatedTarget instanceof Node && !this.contains(relatedTarget));
if (focusLeftCarousel) {
this.resumeByInteraction();
}
}
// Mouse hover handlers for slides container only
private handleSlidesMouseEnter() {
if (this.autoRotateMode) {
this.pauseByInteraction();
}
}
private handleSlidesMouseLeave() {
if (this.autoRotateMode) {
this.resumeByInteraction();
}
}
private handlePointerDown(event: PointerEvent) {
if (this.slides.length < 2) return; // Disable swipe for single slide
if (this.activePointerId !== null) return; // Ignore if already tracking a pointer
this.activePointerId = event.pointerId;
this.pointerStartX = event.clientX;
this.isDragging = true;
this.dragOffset = 0;
// Capture pointer to receive events even if pointer moves outside element
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
// Add dragging class
this.slidesContainer?.classList.add('apg-carousel-slides--dragging');
if (this.autoRotateMode) {
this.pauseByInteraction();
}
}
private handlePointerMove(event: PointerEvent) {
if (this.activePointerId !== event.pointerId) return; // Ignore other pointers
if (this.pointerStartX === null || !this.isDragging) return;
const diff = event.clientX - this.pointerStartX;
this.pendingDragOffset = diff;
// Throttle updates using requestAnimationFrame
if (this.rafRef === null) {
this.rafRef = requestAnimationFrame(() => {
this.dragOffset = this.pendingDragOffset;
this.rafRef = null;
this.updateSwipeVisual();
});
}
}
private updateSwipeVisual() {
const diff = this.dragOffset;
// Compute which adjacent slide to show during drag
const swipeAdjacentSlide =
diff !== 0
? diff > 0
? (this.currentSlide - 1 + this.slides.length) % this.slides.length // swiping right, show prev
: (this.currentSlide + 1) % this.slides.length // swiping left, show next
: null;
// Update slides for swipe visual
this.slides.forEach((slide, i) => {
const isActive = i === this.currentSlide;
const isSwipeAdjacent = i === swipeAdjacentSlide;
// Only active slide is exposed to AT, adjacent is visual-only
slide.setAttribute('aria-hidden', (!isActive).toString());
if (isActive) {
slide.removeAttribute('inert');
} else {
slide.setAttribute('inert', '');
}
// Remove animation classes during drag
slide.classList.remove(
'apg-carousel-slide--entering-next',
'apg-carousel-slide--entering-prev',
'apg-carousel-slide--exiting-next',
'apg-carousel-slide--exiting-prev'
);
// Add/remove swipe classes
slide.classList.remove(
'apg-carousel-slide--swipe-prev',
'apg-carousel-slide--swipe-next'
);
if (isSwipeAdjacent) {
slide.classList.add(
diff > 0 ? 'apg-carousel-slide--swipe-prev' : 'apg-carousel-slide--swipe-next'
);
}
// Apply transform to active and adjacent slides
if (isActive) {
slide.style.transform = `translateX(${diff}px)`;
} else if (isSwipeAdjacent) {
// Position adjacent slide next to current slide
const baseOffset = diff > 0 ? '-100%' : '100%';
slide.style.transform = `translateX(calc(${baseOffset} + ${diff}px))`;
} else {
slide.style.transform = '';
}
});
}
private handlePointerUp(event: PointerEvent) {
if (this.activePointerId !== event.pointerId) return; // Ignore other pointers
if (!this.isDragging || this.pointerStartX === null) return;
const diff = event.clientX - this.pointerStartX;
const containerWidth = this.slidesContainer?.offsetWidth || 300;
const threshold = containerWidth * 0.2; // 20% of container width
if (diff > threshold) {
// Swiped right - go to previous slide (instant, no animation)
this.goToSlideInstant(this.currentSlide - 1);
} else if (diff < -threshold) {
// Swiped left - go to next slide (instant, no animation)
this.goToSlideInstant(this.currentSlide + 1);
} else {
// Snap back - reset slide styles
this.slides.forEach((slide, i) => {
const isActive = i === this.currentSlide;
slide.setAttribute('aria-hidden', (!isActive).toString());
if (isActive) {
slide.removeAttribute('inert');
} else {
slide.setAttribute('inert', '');
}
slide.classList.remove(
'apg-carousel-slide--swipe-prev',
'apg-carousel-slide--swipe-next'
);
slide.style.transform = '';
});
}
// Cancel any pending RAF
if (this.rafRef !== null) {
cancelAnimationFrame(this.rafRef);
this.rafRef = null;
}
this.activePointerId = null;
this.pointerStartX = null;
this.isDragging = false;
this.dragOffset = 0;
// Reset visual feedback
this.slidesContainer?.classList.remove('apg-carousel-slides--dragging');
if (this.autoRotateMode) {
this.resumeByInteraction();
}
}
private handlePointerCancel(event: PointerEvent) {
if (this.activePointerId !== event.pointerId) return; // Ignore other pointers
// Cancel any pending RAF
if (this.rafRef !== null) {
cancelAnimationFrame(this.rafRef);
this.rafRef = null;
}
this.activePointerId = null;
this.pointerStartX = null;
this.isDragging = false;
this.dragOffset = 0;
// Reset visual feedback
this.slidesContainer?.classList.remove('apg-carousel-slides--dragging');
// Reset slide styles
this.slides.forEach((slide, i) => {
const isActive = i === this.currentSlide;
slide.setAttribute('aria-hidden', (!isActive).toString());
if (isActive) {
slide.removeAttribute('inert');
} else {
slide.setAttribute('inert', '');
}
slide.classList.remove(
'apg-carousel-slide--swipe-prev',
'apg-carousel-slide--swipe-next'
);
slide.style.transform = '';
});
}
}
// Register the custom element
if (!customElements.get('apg-carousel')) {
customElements.define('apg-carousel', ApgCarousel);
}
</script>
</apg-carousel> Usage
---
import Carousel from '@patterns/carousel/Carousel.astro';
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' }
];
---
<Carousel
slides={slides}
aria-label="Featured content"
autoRotate={true}
rotationInterval={5000}
/> 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 |
id | string | auto-generated | Instance ID for the carousel |
CarouselSlide Interface
interface CarouselSlide {
id: string;
content: string;
label?: string;
} Custom Events
| Event | Detail | Description |
|---|---|---|
slidechange | { index: number } | Dispatched 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 |
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.
/**
* Carousel Astro Component Tests using Container API
*
* These tests verify the Carousel.astro component output using Astro's Container API.
* This ensures the component renders correct ARIA structure and attributes.
*
* Note: Interactive behavior (keyboard, auto-rotation) is tested in E2E tests.
*
* @see https://docs.astro.build/en/reference/container-reference/
*/
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import Carousel from './Carousel.astro';
describe('Carousel (Astro Container API)', () => {
let container: AstroContainer;
beforeEach(async () => {
container = await AstroContainer.create();
});
// Helper to render and parse HTML
async function renderCarousel(props: {
slides: Array<{ id: string; content: string; label?: string }>;
'aria-label': string;
initialSlide?: number;
autoRotate?: boolean;
rotationInterval?: number;
class?: string;
id?: string;
}): Promise<Document> {
const html = await container.renderToString(Carousel, { props });
const dom = new JSDOM(html);
return dom.window.document;
}
const basicSlides = [
{ 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 = [
{ 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' },
];
// 🔴 High Priority: APG ARIA Structure
describe('APG: ARIA Structure', () => {
it('has aria-roledescription="carousel" on container', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const carousel = doc.querySelector('section');
expect(carousel?.getAttribute('aria-roledescription')).toBe('carousel');
});
it('has aria-label on container', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const carousel = doc.querySelector('section');
expect(carousel?.getAttribute('aria-label')).toBe('Featured content');
});
it('has aria-roledescription="slide" on each tabpanel', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const panels = doc.querySelectorAll('[role="tabpanel"]');
expect(panels).toHaveLength(3);
panels.forEach((panel) => {
expect(panel.getAttribute('aria-roledescription')).toBe('slide');
});
});
it('has aria-label="N of M" on each slide', async () => {
const doc = await renderCarousel({
slides: fiveSlides,
'aria-label': 'Featured content',
});
const panels = doc.querySelectorAll('[role="tabpanel"]');
expect(panels[0]?.getAttribute('aria-label')).toBe('1 of 5');
expect(panels[1]?.getAttribute('aria-label')).toBe('2 of 5');
expect(panels[2]?.getAttribute('aria-label')).toBe('3 of 5');
expect(panels[3]?.getAttribute('aria-label')).toBe('4 of 5');
expect(panels[4]?.getAttribute('aria-label')).toBe('5 of 5');
});
});
describe('APG: Tablist ARIA', () => {
it('has role="tablist" on tab container', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const tablist = doc.querySelector('[role="tablist"]');
expect(tablist).not.toBeNull();
});
it('has role="tab" on each tab button', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const tabs = doc.querySelectorAll('[role="tab"]');
expect(tabs).toHaveLength(3);
});
it('has role="tabpanel" on each slide', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const panels = doc.querySelectorAll('[role="tabpanel"]');
expect(panels).toHaveLength(3);
});
it('has aria-selected="true" on first tab by default', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const tabs = doc.querySelectorAll('[role="tab"]');
expect(tabs[0]?.getAttribute('aria-selected')).toBe('true');
expect(tabs[1]?.getAttribute('aria-selected')).toBe('false');
expect(tabs[2]?.getAttribute('aria-selected')).toBe('false');
});
it('has aria-selected="true" on initialSlide tab', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
initialSlide: 1,
});
const tabs = doc.querySelectorAll('[role="tab"]');
expect(tabs[0]?.getAttribute('aria-selected')).toBe('false');
expect(tabs[1]?.getAttribute('aria-selected')).toBe('true');
expect(tabs[2]?.getAttribute('aria-selected')).toBe('false');
});
it('has aria-controls pointing to tabpanel', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const tabs = doc.querySelectorAll('[role="tab"]');
const panels = doc.querySelectorAll('[role="tabpanel"]');
tabs.forEach((tab, index) => {
const controls = tab.getAttribute('aria-controls');
const panelId = panels[index]?.getAttribute('id');
expect(controls).toBe(panelId);
});
});
it('panel aria-labelledby matches tab id', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const tabs = doc.querySelectorAll('[role="tab"]');
const panels = doc.querySelectorAll('[role="tabpanel"]');
panels.forEach((panel, index) => {
const labelledby = panel.getAttribute('aria-labelledby');
const tabId = tabs[index]?.getAttribute('id');
expect(labelledby).toBe(tabId);
});
});
});
// 🔴 High Priority: Auto-Rotation State
describe('APG: Auto-Rotation', () => {
it('has aria-live="off" when autoRotate is true', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
autoRotate: true,
});
const slidesContainer = doc.querySelector('[data-testid="slides-container"]');
expect(slidesContainer?.getAttribute('aria-live')).toBe('off');
});
it('has aria-live="polite" when autoRotate is false', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
autoRotate: false,
});
const slidesContainer = doc.querySelector('[data-testid="slides-container"]');
expect(slidesContainer?.getAttribute('aria-live')).toBe('polite');
});
it('has play/pause button when autoRotate is true', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
autoRotate: true,
});
const playPauseButton = doc.querySelector(
'button[aria-label*="Stop"], button[aria-label*="Pause"]'
);
expect(playPauseButton).not.toBeNull();
});
});
// 🔴 High Priority: Focus Management
describe('APG: Focus Management', () => {
it('uses roving tabindex on tablist', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const tabs = doc.querySelectorAll('[role="tab"]');
expect(tabs[0]?.getAttribute('tabindex')).toBe('0');
expect(tabs[1]?.getAttribute('tabindex')).toBe('-1');
expect(tabs[2]?.getAttribute('tabindex')).toBe('-1');
});
it('active tab has tabindex="0"', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
initialSlide: 1,
});
const tabs = doc.querySelectorAll('[role="tab"]');
expect(tabs[0]?.getAttribute('tabindex')).toBe('-1');
expect(tabs[1]?.getAttribute('tabindex')).toBe('0');
expect(tabs[2]?.getAttribute('tabindex')).toBe('-1');
});
});
// 🟡 Medium Priority: Navigation Controls
describe('Navigation Controls', () => {
it('has previous button', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const prevButton = doc.querySelector(
'button[aria-label*="Previous"], button[aria-label*="Prev"]'
);
expect(prevButton).not.toBeNull();
});
it('has next button', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const nextButton = doc.querySelector('button[aria-label*="Next"]');
expect(nextButton).not.toBeNull();
});
it('prev/next buttons have aria-controls', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
});
const prevButton = doc.querySelector(
'button[aria-label*="Previous"], button[aria-label*="Prev"]'
);
const nextButton = doc.querySelector('button[aria-label*="Next"]');
const slidesContainer = doc.querySelector('[data-testid="slides-container"]');
expect(prevButton?.getAttribute('aria-controls')).toBe(slidesContainer?.getAttribute('id'));
expect(nextButton?.getAttribute('aria-controls')).toBe(slidesContainer?.getAttribute('id'));
});
});
// 🟢 Low Priority: HTML Attributes
describe('HTML Attributes', () => {
it('applies class to container', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
class: 'custom-carousel',
});
const carousel = doc.querySelector('section');
expect(carousel?.classList.contains('custom-carousel')).toBe(true);
});
it('applies id to container', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
id: 'my-carousel',
});
const carousel = doc.querySelector('section');
expect(carousel?.getAttribute('id')).toBe('my-carousel');
});
});
// Edge Cases
describe('Edge Cases', () => {
it('handles single slide', async () => {
const singleSlide = [{ id: 'slide1', content: '<div>Only Slide</div>', label: 'Only' }];
const doc = await renderCarousel({
slides: singleSlide,
'aria-label': 'Featured content',
});
const tabs = doc.querySelectorAll('[role="tab"]');
expect(tabs).toHaveLength(1);
const panel = doc.querySelector('[role="tabpanel"]');
expect(panel?.getAttribute('aria-label')).toBe('1 of 1');
});
it('clamps initialSlide to valid range', async () => {
const doc = await renderCarousel({
slides: basicSlides,
'aria-label': 'Featured content',
initialSlide: 99,
});
const tabs = doc.querySelectorAll('[role="tab"]');
// Should fallback to first slide
expect(tabs[0]?.getAttribute('aria-selected')).toBe('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