Default

<div class="carousel" role="region" aria-roledescription="carousel" aria-label="Featured slides">
  <div class="carousel-viewport" aria-live="polite">
    <div class="carousel-slide" role="group" aria-roledescription="slide" aria-label="1 of 5">1</div>
    <div class="carousel-slide" role="group" aria-roledescription="slide" aria-label="2 of 5">2</div>
    <div class="carousel-slide" role="group" aria-roledescription="slide" aria-label="3 of 5">3</div>
  </div>
  <button class="carousel-prev" aria-label="Previous slide"><i data-lucide="chevron-left"></i></button>
  <button class="carousel-next" aria-label="Next slide"><i data-lucide="chevron-right"></i></button>
</div>

Sizes

Set flex on .carousel-slide to show multiple slides. Use calc() to account for the gap.

<!-- 33% width per slide (3 visible) -->
<div class="carousel-slide" style="flex: 0 0 calc(33.333% - 0.667rem);">…</div>

<!-- 50% width per slide (2 visible) -->
<div class="carousel-slide" style="flex: 0 0 calc(50% - 0.5rem);">…</div>

With Dot Indicators

Add an empty .carousel-dots container — dots are auto-generated from the slide count.

<div class="carousel" role="region" aria-roledescription="carousel" aria-label="Gallery">
  <div class="carousel-viewport" aria-live="polite">
    <div class="carousel-slide" role="group" aria-roledescription="slide">…</div>
    <!-- more slides -->
  </div>
  <button class="carousel-prev" aria-label="Previous slide">…</button>
  <button class="carousel-next" aria-label="Next slide">…</button>
  <div class="carousel-dots" role="group" aria-label="Choose slide to display">
    <!-- dots auto-generated by JS -->
  </div>
</div>

With Counter

Add a .carousel-counter element — JS updates it automatically.

<div class="carousel" role="region" aria-roledescription="carousel" aria-label="Slides">
  <div class="carousel-viewport" aria-live="polite">…</div>
  <button class="carousel-prev" aria-label="Previous slide">…</button>
  <button class="carousel-next" aria-label="Next slide">…</button>
  <div class="carousel-counter" aria-live="polite">Slide 1 of 5</div>
</div>

Vertical

Set data-orientation="vertical" and give the viewport a fixed height.

<div class="carousel" data-orientation="vertical" role="region" aria-roledescription="carousel" aria-label="Vertical slides">
  <div class="carousel-viewport" aria-live="polite" style="height: 14rem;">
    <div class="carousel-slide" role="group" aria-roledescription="slide">…</div>
  </div>
  <button class="carousel-prev" aria-label="Previous slide"><i data-lucide="chevron-up"></i></button>
  <button class="carousel-next" aria-label="Next slide"><i data-lucide="chevron-down"></i></button>
</div>

Loop

Add data-loop for infinite circular navigation — buttons never disable.

<div class="carousel" data-loop role="region" aria-roledescription="carousel" aria-label="Looping slides">
  <div class="carousel-viewport" aria-live="polite">…</div>
  <button class="carousel-prev" aria-label="Previous slide">…</button>
  <button class="carousel-next" aria-label="Next slide">…</button>
  <div class="carousel-dots" role="group" aria-label="Choose slide to display"></div>
</div>

Autoplay

Set data-autoplay="3000" (in milliseconds). Pauses on hover and focus per WAI-ARIA requirements.

<div class="carousel" data-autoplay="3000" data-loop role="region" aria-roledescription="carousel" aria-label="Auto slides">
  <div class="carousel-viewport" aria-live="off">…</div>
  <button class="carousel-prev" aria-label="Previous slide">…</button>
  <button class="carousel-next" aria-label="Next slide">…</button>
  <div class="carousel-dots" role="group" aria-label="Choose slide to display"></div>
</div>

With Cards

Compose with the Card component for richer slide content.

CSS view file

Component styles using scroll-snap, design tokens, and accessibility media queries.

@layer components {
  .carousel {
    position: relative;
    width: 100%;

    /* ── Viewport ──────────────────────────────── */
    & .carousel-viewport {
      display: flex;
      overflow-x: auto;
      scroll-snap-type: x mandatory;
      gap: 1rem;
      overscroll-behavior-x: contain;
      scrollbar-width: none;
      -webkit-overflow-scrolling: touch;

      &::-webkit-scrollbar { display: none; }
    }

    /* ── Slide ─────────────────────────────────── */
    & .carousel-slide {
      flex: 0 0 100%;
      scroll-snap-align: start;
      scroll-snap-stop: always;
      min-width: 0;
    }

    /* ── Prev / Next buttons ───────────────────── */
    & .carousel-prev,
    & .carousel-next {
      position: absolute;
      top: 50%;
      translate: 0 -50%;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 2rem;
      height: 2rem;
      border: 1px solid var(--border);
      border-radius: 9999px;
      background-color: var(--background);
      color: var(--foreground);
      cursor: pointer;
      box-shadow: var(--shadow-sm);
      transition: background-color 150ms ease, opacity 150ms ease;
      font-size: 0.875rem;
      z-index: 1;

      &:hover { background-color: var(--accent); }
      &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
      &:disabled { opacity: 0.5; cursor: not-allowed; pointer-events: none; }
      & svg { width: 1rem; height: 1rem; }
    }

    & .carousel-prev { left: -1rem; }
    & .carousel-next { right: -1rem; }

    /* ── Dot indicators ────────────────────────── */
    & .carousel-dots {
      display: flex;
      justify-content: center;
      gap: 0.5rem;
      padding-block: 0.75rem;
    }

    & .carousel-dot {
      width: 0.5rem;
      height: 0.5rem;
      border-radius: 9999px;
      border: none;
      padding: 0;
      cursor: pointer;
      background-color: var(--border);
      transition: background-color 150ms ease, scale 150ms ease;

      &[aria-current="true"] {
        background-color: var(--foreground);
        scale: 1.25;
      }

      &:hover:not([aria-current="true"]) {
        background-color: var(--muted-foreground);
      }

      &:focus-visible {
        outline: 2px solid var(--ring);
        outline-offset: 2px;
      }
    }

    /* ── Counter text ──────────────────────────── */
    & .carousel-counter {
      text-align: center;
      font-size: 0.8125rem;
      color: var(--muted-foreground);
      padding-block-start: 0.5rem;
    }

    /* ── Vertical orientation ──────────────────── */
    &[data-orientation="vertical"] {
      & .carousel-viewport {
        flex-direction: column;
        overflow-x: hidden;
        overflow-y: auto;
        scroll-snap-type: y mandatory;
        overscroll-behavior-x: unset;
        overscroll-behavior-y: contain;
      }

      & .carousel-prev,
      & .carousel-next {
        left: 50%;
        right: auto;
        translate: -50% 0;
        top: auto;
      }
      & .carousel-prev { top: -1rem; bottom: auto; }
      & .carousel-next { bottom: -1rem; top: auto; }

      & .carousel-dots { flex-direction: column; }
    }
  }

  /* ── Accessibility ─────────────────────────── */
  @media (prefers-reduced-motion: reduce) {
    .carousel .carousel-viewport { scroll-behavior: auto; }
    .carousel .carousel-prev,
    .carousel .carousel-next { transition: none; }
    .carousel .carousel-dot { transition: none; }
  }

  @media (prefers-contrast: more) {
    .carousel .carousel-prev,
    .carousel .carousel-next {
      border-width: 2px;
    }
    .carousel .carousel-dot {
      border: 1px solid var(--foreground);
    }
  }

  @media (forced-colors: active) {
    .carousel .carousel-prev,
    .carousel .carousel-next {
      border: 1px solid ButtonText;
      background: ButtonFace;
      color: ButtonText;
    }
    .carousel .carousel-dot {
      background: ButtonText;
      &[aria-current="true"] { background: Highlight; }
    }
  }
}

JavaScript view file

Scroll-snap carousel with IntersectionObserver tracking, ARIA, keyboard navigation, dots, loop, and autoplay.

// -- Carousel -------------------------------------------------
// Scroll-snap carousel with keyboard navigation, prev/next buttons,
// dot indicators, loop, autoplay, and ARIA.

function init() {
document.querySelectorAll('.carousel:not([data-init])').forEach((carousel) => {
  carousel.dataset.init = '';

  const viewport = carousel.querySelector('.carousel-viewport');
  const prevBtn = carousel.querySelector('.carousel-prev');
  const nextBtn = carousel.querySelector('.carousel-next');
  const dotsContainer = carousel.querySelector('.carousel-dots');
  const counter = carousel.querySelector('.carousel-counter');
  if (!viewport) return;

  const slides = () => Array.from(viewport.querySelectorAll('.carousel-slide'));
  const isVertical = carousel.dataset.orientation === 'vertical';
  const isLoop = carousel.hasAttribute('data-loop');
  const autoplayDelay = carousel.dataset.autoplay ? parseInt(carousel.dataset.autoplay, 10) : 0;
  const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
  const behavior = reducedMotion ? 'auto' : 'smooth';

  let currentIndex = 0;
  let autoplayTimer = null;

  // ── ARIA setup ───────────────────────────────
  if (!carousel.hasAttribute('role')) carousel.setAttribute('role', 'region');
  carousel.setAttribute('aria-roledescription', 'carousel');
  if (!carousel.hasAttribute('aria-label')) carousel.setAttribute('aria-label', 'Carousel');

  slides().forEach((slide, i) => {
    slide.setAttribute('role', 'group');
    slide.setAttribute('aria-roledescription', 'slide');
    if (!slide.hasAttribute('aria-label')) {
      slide.setAttribute('aria-label', `${i + 1} of ${slides().length}`);
    }
  });

  // ── Scroll to index ─────────────────────────
  const scrollToIndex = (index) => {
    const allSlides = slides();
    if (!allSlides.length) return;

    let target = index;
    if (isLoop) {
      target = ((index % allSlides.length) + allSlides.length) % allSlides.length;
    } else {
      target = Math.max(0, Math.min(index, allSlides.length - 1));
    }

    const slide = allSlides[target];
    if (isVertical) {
      viewport.scrollTo({ top: slide.offsetTop - viewport.offsetTop, behavior });
    } else {
      viewport.scrollTo({ left: slide.offsetLeft - viewport.offsetLeft, behavior });
    }
  };

  // ── Update state (buttons, dots, counter) ───
  const updateState = (index) => {
    const allSlides = slides();
    if (!allSlides.length) return;
    currentIndex = index;

    // Prev/next disabled states (non-loop)
    if (!isLoop) {
      if (prevBtn) prevBtn.disabled = currentIndex <= 0;
      if (nextBtn) nextBtn.disabled = currentIndex >= allSlides.length - 1;
    }

    // Dot indicators
    if (dotsContainer) {
      const dots = dotsContainer.querySelectorAll('.carousel-dot');
      dots.forEach((dot, i) => {
        dot.setAttribute('aria-current', i === currentIndex ? 'true' : 'false');
      });
    }

    // Counter
    if (counter) {
      counter.textContent = `Slide ${currentIndex + 1} of ${allSlides.length}`;
    }

    // ARIA labels on slides
    allSlides.forEach((slide, i) => {
      slide.setAttribute('aria-label', `${i + 1} of ${allSlides.length}`);
    });
  };

  // ── IntersectionObserver for current slide ──
  const observer = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
          const idx = slides().indexOf(entry.target);
          if (idx !== -1) updateState(idx);
        }
      }
    },
    { root: viewport, threshold: 0.5 }
  );

  slides().forEach((slide) => observer.observe(slide));

  // ── Navigation ──────────────────────────────
  const goNext = () => scrollToIndex(currentIndex + 1);
  const goPrev = () => scrollToIndex(currentIndex - 1);

  if (prevBtn) prevBtn.addEventListener('click', goPrev);
  if (nextBtn) nextBtn.addEventListener('click', goNext);

  // ── Dot click handlers ──────────────────────
  if (dotsContainer) {
    const allSlides = slides();
    // Generate dots if empty
    if (!dotsContainer.children.length && allSlides.length) {
      allSlides.forEach((_, i) => {
        const dot = document.createElement('button');
        dot.className = 'carousel-dot';
        dot.setAttribute('aria-label', `Go to slide ${i + 1}`);
        dot.setAttribute('aria-current', i === 0 ? 'true' : 'false');
        dotsContainer.appendChild(dot);
      });
    }

    dotsContainer.addEventListener('click', (e) => {
      const dot = e.target.closest('.carousel-dot');
      if (!dot) return;
      const dots = Array.from(dotsContainer.querySelectorAll('.carousel-dot'));
      const idx = dots.indexOf(dot);
      if (idx !== -1) scrollToIndex(idx);
    });
  }

  // ── Keyboard navigation ─────────────────────
  carousel.addEventListener('keydown', (e) => {
    const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft';
    const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight';
    if (e.key === prevKey) { e.preventDefault(); goPrev(); }
    if (e.key === nextKey) { e.preventDefault(); goNext(); }
    if (e.key === 'Home') { e.preventDefault(); scrollToIndex(0); }
    if (e.key === 'End') { e.preventDefault(); scrollToIndex(slides().length - 1); }
  });

  // Make carousel focusable if not already
  if (!carousel.hasAttribute('tabindex')) {
    carousel.setAttribute('tabindex', '0');
  }

  // ── Autoplay ────────────────────────────────
  const startAutoplay = () => {
    if (!autoplayDelay) return;
    stopAutoplay();
    autoplayTimer = setInterval(goNext, autoplayDelay);
    viewport.setAttribute('aria-live', 'off');
  };

  const stopAutoplay = () => {
    if (autoplayTimer) {
      clearInterval(autoplayTimer);
      autoplayTimer = null;
    }
    viewport.setAttribute('aria-live', 'polite');
  };

  if (autoplayDelay) {
    startAutoplay();
    // Pause on hover and focus (WAI-ARIA APG requirement)
    carousel.addEventListener('mouseenter', stopAutoplay);
    carousel.addEventListener('mouseleave', startAutoplay);
    carousel.addEventListener('focusin', stopAutoplay);
    carousel.addEventListener('focusout', startAutoplay);
  } else {
    viewport.setAttribute('aria-live', 'polite');
  }

  // ── Initial state ───────────────────────────
  updateState(0);
});
}

init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });