Basic Carousel
A slide carousel with prev/next arrows, dots and a polite live region.
6 frameworksIntermediate
Three cards at a time on desktop, one on mobile, built on scroll-snap.
<!--
Three across on desktop, one on mobile - built on scroll-snap rather than a
transform, which buys three things a translateX carousel has to fake:
- the browser does the responsive maths (the slide is a flex-basis, and how
many fit is whatever fits); no matchMedia, no per-breakpoint index juggling
- touch swipe and trackpad scroll work for free, with native momentum
- the track is a focusable scroll region (tabindex="0" + role="group" + label),
so keyboard users can scroll it with the arrow keys the browser gives them
The buttons scroll by exactly one slide, measured from the DOM.
-->
<section class="multi" aria-roledescription="carousel" aria-label="Feature tour" data-multi>
<div class="multi__header">
<h2 class="multi__heading">What's inside</h2>
<div class="multi__arrows">
<button class="multi__arrow" type="button" aria-label="Previous slides" data-multi-prev>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="m12.5 5-5 5 5 5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button class="multi__arrow" type="button" aria-label="Next slides" data-multi-next>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="m7.5 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</div>
<div class="multi__track" role="group" aria-label="Slides, scrollable" tabindex="0" data-multi-track>
<article class="multi__slide" role="group" aria-roledescription="slide" aria-label="1 of 5">
<h3 class="multi__title">Analytics</h3>
<p class="multi__copy">Every metric that matters, live.</p>
</article>
<article class="multi__slide" role="group" aria-roledescription="slide" aria-label="2 of 5">
<h3 class="multi__title">Automations</h3>
<p class="multi__copy">Turn a repeated click into a rule.</p>
</article>
<article class="multi__slide" role="group" aria-roledescription="slide" aria-label="3 of 5">
<h3 class="multi__title">Integrations</h3>
<p class="multi__copy">Forty connectors and a webhook.</p>
</article>
<article class="multi__slide" role="group" aria-roledescription="slide" aria-label="4 of 5">
<h3 class="multi__title">Audit log</h3>
<p class="multi__copy">Who changed what, and when.</p>
</article>
<article class="multi__slide" role="group" aria-roledescription="slide" aria-label="5 of 5">
<h3 class="multi__title">Reports</h3>
<p class="multi__copy">Scheduled, and in your inbox.</p>
</article>
</div>
</section>
<script>
(function () {
document.querySelectorAll('[data-multi]').forEach(function (root) {
var track = root.querySelector('[data-multi-track]');
// Measure one step from the DOM: slide width + the flex gap. Nothing here
// needs to know the breakpoint - whatever CSS decided, this reads back.
function step() {
var slide = track.firstElementChild;
if (!slide) return 0;
var gap = parseFloat(getComputedStyle(track).columnGap || '0') || 0;
return slide.getBoundingClientRect().width + gap;
}
var behavior = window.matchMedia('(prefers-reduced-motion: reduce)').matches
? 'auto'
: 'smooth';
root.querySelector('[data-multi-prev]').addEventListener('click', function () {
track.scrollBy({ left: -step(), behavior: behavior });
});
root.querySelector('[data-multi-next]').addEventListener('click', function () {
track.scrollBy({ left: step(), behavior: behavior });
});
});
})();
</script>| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | Slide[] | - | The array of items to render. |
onSelect | (index: number) => void | - | Fired with the menu item the user chose. |
className | string | - | Additional classes merged onto the root element. |
Scroll-snap rather than a transform, which buys three things a `translateX` carousel has to fake. The browser does the responsive maths - the slide is a flex-basis and "how many fit" is whatever fits, so there is no `matchMedia` and no per-breakpoint index juggling. Touch swipe and trackpad scroll work for free. And the track is a focusable scroll region (`tabIndex={0}` + `role="group"` + a label), so keyboard users get the browser's own arrow-key scrolling. The buttons measure one step from the DOM (slide width + the computed `column-gap`), so changing `basis-[calc((100%-2rem)/3)]` to four across needs no JS edit at all.