APG Patterns
日本語 GitHub
日本語 GitHub

Carousel

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

🤖 AI Implementation Guide

Demo

Manual Navigation

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

Auto-Rotation

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

Accessibility Features

WAI-ARIA Roles

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

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

WAI-ARIA Properties

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

WAI-ARIA States

aria-selected

Indicates the currently selected slide indicator tab.

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

aria-live

Dynamically controls screen reader announcements based on rotation state.

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

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

Keyboard Support

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

Auto-Rotation Behavior

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

Source Code

Carousel.astro
---
/**
 * 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

Example
---
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

Types
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

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

Carousel.test.astro.ts
/**
 * 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