Theme
On this page (11)
Component Skill — components/carousel/component-skill.md

Native basis

CSS scroll-snap on an overflow container, with IntersectionObserver for active-slide tracking and native <button> elements for navigation.

Web Platform APIs

scroll-snap-typescroll-snap-alignscroll-snap-stopoverscroll-behaviorIntersectionObserver:focus-visibleprefers-reduced-motionprefers-contrastforced-colorsWAI-ARIA Carousel

Classes

.carousel.carousel-viewport.carousel-slide.carousel-prev.carousel-next.carousel-dots.carousel-dot.carousel-counter

§Default

§Sizes

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

§With Dot Indicators

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

§With Counter

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

§Vertical

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

§Loop

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

§Autoplay

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

§With Cards

Compose with the Card component for richer slide content.

§States

Named states via the shared State API, driven per instance through the bound api:

  • default - showing a slide; setState('default', { index }) scrolls to that slide (loop-aware), and getState().config.index reports the live index

The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/carousel-{state}.png.

Machine contract - verified against carousel.schema.json by bun run verify:

StateTypeValuesDefaultDescription
activeSlidenumber—0Index of the slide in view (setState('default', { index })); mirrored as data-current-index and updated by nav/dots/swipe.

§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, plus the named-state API
// (AGENTS.md "State API"). The carousel's observable state is which slide is
// showing, so 'default' carries an optional { index } preset (0 = first) and
// getState().config.index reports the live slide index.
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
// defussQuery: the callable runtime - dots are (re)rendered through keyed
// morph, flags ride .attr()/.prop() (plans/defuss-query-morph-integration.md
// §3 carousel row).
import { defussGlobals, defussQuery } from '../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// id prefix source for carousels without their own #id (unique per element)
let carSeq = 0;
const carouselStates = ['default'];
/**
 * UI side of setState: scroll to a slide index (clamped/looped by the
 * carousel's own scrollToIndex, exposed on the element at init).
 */
function triggerStateChange(carousel, config) {
  const index = Number(config?.index ?? 0);
  if (typeof carousel._goTo === 'function') carousel._goTo(index);
}
/** Registry-level API; pass the carousel element explicitly. Unknown names throw. */
export const carouselApi = {
  setState(carousel, stateName, config = {}) {
    if (!carouselStates.includes(stateName)) {
      throw new Error(`carousel: unknown state "${stateName}" (supported: ${carouselStates.join(', ')})`);
    }
    triggerStateChange(carousel, config);
    // state lives on the ELEMENT, not the module (many carousels per page)
    carousel.dataset.stateName = stateName;
    carousel._stateConfig = config;
  },
  getState(carousel) {
    return {
      name: carousel.dataset.stateName || 'default',
      // live slide index - updated by updateState() on scroll, not just setState
      config: { ...carousel._stateConfig, index: Number(carousel.dataset.currentIndex || 0) },
    };
  },
};
df$.carouselApi = carouselApi;
df$.carouselStates = carouselStates;
function init() {
document.querySelectorAll('.carousel:not([data-init])').forEach((carousel) => {
  carousel.dataset.init = '';
  // bind-scope the api per instance: `$('#gallery').api.setState('default', { index: 2 })`
  carousel.api = {
    setState: (stateName, config) => carouselApi.setState(carousel, stateName, config),
    getState: () => carouselApi.getState(carousel),
  };
  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;
    // mirror the live index onto the element for the State API (AGENTS.md:
    // state must not live in module scope)
    carousel.dataset.currentIndex = String(index);
    // Prev/next disabled states (non-loop) - native IDL flags via .prop()
    if (!isLoop) {
      if (prevBtn) dfDollar(prevBtn).prop('disabled', currentIndex <= 0);
      if (nextBtn) dfDollar(nextBtn).prop('disabled', currentIndex >= allSlides.length - 1);
    }
    // Dot indicators - scalar ARIA flag per dot through query (§3: attr, no re-render)
    if (dotsContainer)
      dfDollar(dotsContainer)
        .find('.carousel-dot')
        .each(function (this: HTMLElement, i: number) { dfDollar(this).attr('aria-current', i === currentIndex ? 'true' : 'false'); });
    // Counter (literal template text, consumer-visible label)
    if (counter) dfDollar(counter).text(`Slide ${currentIndex + 1} of ${allSlides.length}`);
    // ARIA labels on slides
    allSlides.forEach((slide, i) => { dfDollar(slide).attr('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);
  // expose the closure's scroll-to for the State API (element member, not module)
  carousel._goTo = scrollToIndex;
  // ── Dots ─────────────────────────────────────
  // Dot structure renders through morph with stable id keys (slides are
  // consumer-authored, fixed order → index ids ARE the identity, §3). An
  // empty container morphs the initial dot list; when slides change out of
  // band, a slide-count check short-circuits unless reconciliation is due —
  // then ONE keyed morph pass reconciles instead of hand-building buttons.
  // Consumer-provided dots stay consumer-owned (never re-rendered; only
  // their aria-current flag is maintained).
  const carId = (carousel.dataset.carouselId ||= carousel.id || `dfsc-${++carSeq}`);
  let dotCount = -1;
  const renderDots = () => {
    if (!dotsContainer) return;
    const n = slides().length;
    if (dotCount === -1 && dotsContainer.children.length) { dotCount = n; return; } // consumer dots
    if (n === dotCount) return; // structure already matches the slide count
    dotCount = n;
    const html = Array.from({ length: n }, (_, i) =>
      `<button id="${carId}-dot-${i}" class="carousel-dot" aria-label="Go to slide ${i + 1}" aria-current="${i === currentIndex ? 'true' : 'false'}"></button>`,
    ).join('');
    dfDollar(dotsContainer).morph(html); // trusted static markup (§5.1 sink rule)
  };
  if (dotsContainer) {
    renderDots();
    dotsContainer.addEventListener('click', (e) => {
      const dot = e.target.closest('.carousel-dot');
      if (!dot) return;
      const idx = Array.from(dotsContainer.querySelectorAll('.carousel-dot')).indexOf(dot);
      if (idx !== -1) scrollToIndex(idx);
    });
  }
  // keep the dot structure in sync when slides change out of band
  let lastSlideCount = slides().length;
  const syncObserver = new MutationObserver(() => {
    const n = slides().length;
    if (n !== lastSlideCount) {
      lastSlideCount = n;
      renderDots();
      observer.disconnect();
      slides().forEach((slide) => observer.observe(slide));
      updateState(Math.min(currentIndex, Math.max(0, n - 1)));
    }
  });
  if (viewport) syncObserver.observe(viewport, { childList: true });
  // ── 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 });

Comments, ideas or improvements? Edit this page's source on GitHub