Theme
Design your own
On this page (12)
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.

§API

Generated from carousel.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type CarouselState = 'default' - setState(name, config) takes the config of the state it names.

StateDescription
default
The carousel showing one slide.
Config fieldTypeDescription
index?numberthe slide to show, 0-based (default 0)

Every element

MemberDescription
el.api.setState<S extends CarouselState>(name: S, config?: CarouselStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?CarouselStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: CarouselState; config: CarouselStateConfigs[CarouselState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: CarouselState; config: CarouselStateConfigs[CarouselState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: CarouselState; config: CarouselStateConfigs[CarouselState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: CarouselState; config: CarouselStateConfigs[CarouselState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: CarouselState; config: CarouselStateConfigs[CarouselState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.carouselApi.setState<S extends CarouselState>(el: HTMLElement, name: S, config?: CarouselStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?CarouselStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.carouselApi.getState(el: HTMLElement): { name: CarouselState; config: CarouselStateConfigs[CarouselState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: CarouselState; config: CarouselStateConfigs[CarouselState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.carouselApi.render(state: { name: CarouselState; config: CarouselStateConfigs[CarouselState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: CarouselState; config: CarouselStateConfigs[CarouselState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.carouselApi.store(el: HTMLElement): Store<{ name: CarouselState; config: CarouselStateConfigs[CarouselState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: CarouselState; config: CarouselStateConfigs[CarouselState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.carouselApi.commit<S extends CarouselState>(el: HTMLElement, name: S, config?: CarouselStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?CarouselStateConfigs[S]its config
df$.shadcn.carouselStates: CarouselState[]The declared states, 'default' first: default.

§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, componentState, bindComponent } 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'];
// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each
// described, and every config field typed, described and named in the code.
/** setState() configs per state (getState() reports the slide shown). */
export interface CarouselStateConfigs {
  /** The carousel showing one slide. */
  default: {
    /** the slide to show, 0-based (default 0) */
    index?: number;
  };
}
/**
 * The markup of a state, for render(): the attributes a state writes, applied
 * to a detached copy of the authored markup ('default' IS the authored
 * markup). The live element gets the same markup from triggerStateChange -
 * the e2e render round trip proves they agree.
 */
function applyMarkup(_el, _stateName) {
  // one state, and it writes no markup: { index } scrolls the track - the
  // position and the generated dots follow it (runtime-owned, see the e2e)
}
/**
 * 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 = componentState({
  component: 'carousel',
  states: carouselStates,
  apply: (carousel, state) => triggerStateChange(carousel, state.config),
  read: (carousel, state) => {
    return {
      name: carousel.dataset.stateName || 'default',
      // live slide index - updated by updateState() on scroll, not just setState
      config: { ...state.config, index: Number(carousel.dataset.currentIndex || 0) },
    };
  },
  markup: (el, state) => applyMarkup(el, state.name),
});
df$.carouselApi = carouselApi;
df$.carouselStates = carouselStates;
function init() {
  dfDollar('.carousel:not([data-init])')
    .toArray()
    .forEach((carousel) => {
      carousel.dataset.init = '';
      // el.store + el.api (AGENTS.md "State through stores")
      bindComponent(carousel, carouselApi);
      const viewport = dfDollar(carousel).find('.carousel-viewport').get(0);
      const prevBtn = dfDollar(carousel).find('.carousel-prev').get(0);
      const nextBtn = dfDollar(carousel).find('.carousel-next').get(0);
      const dotsContainer = dfDollar(carousel).find('.carousel-dots').get(0);
      const counter = dfDollar(carousel).find('.carousel-counter').get(0);
      if (!viewport) return;
      const slides = () => Array.from(dfDollar(viewport).find('.carousel-slide').toArray());
      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 as HTMLElement);
              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 as HTMLElement).closest<HTMLElement>('.carousel-dot');
          if (!dot) return;
          const idx = Array.from(dfDollar(dotsContainer).find('.carousel-dot').toArray()).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