APG Patterns
日本語
日本語

Carousel

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

Demo

Manual Navigation

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

Auto-Rotation

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

Open demo only →

Accessibility Features

WAI-ARIA Roles

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

WAI-ARIA Properties

aria-roledescription

Announces “carousel” to screen readers

Values
carousel
Required
Yes

aria-roledescription

Announces “slide” instead of “tabpanel”

Values
slide
Required
Yes

aria-label

Describes the carousel purpose

Values
Text
Required
Yes

aria-label

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

Values
N of M
Required
Yes

aria-controls

References controlled element

Values
ID reference
Required
Yes

aria-labelledby

References associated tab

Values
ID reference
Required
Yes

aria-atomic

Only announce changed content

Values
false
Required
No

WAI-ARIA States

aria-selected

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

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

aria-live

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

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

Keyboard Support

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

Focus Management

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

References

Source Code

Carousel.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

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

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

Running Tests

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

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

Testing Tools

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

Carousel.test.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