Carousel
A scrollable content slider built on CSS scroll-snap with keyboard navigation, dot indicators, loop, and autoplay support. No libraries needed.
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 });