CarouselATM
A scrollable content slider built on CSS scroll-snap with keyboard navigation, dot indicators, loop, and autoplay support. No libraries needed.
On this page (11)
§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), andgetState().config.indexreports 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:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
activeSlide | number | — | 0 | Index 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