Basic Carousel
A slide carousel with prev/next arrows, dots and a polite live region.
6 frameworksIntermediate
A card carousel where the next and previous cards peek in from the edges.
<!-- Peek carousel: cards are 85% wide and snap-center, so the neighbours stay
half-visible as a "there is more" cue. Scroll-snap does the paging; the
arrows nudge by exactly one card width measured from the DOM. -->
<section class="max-w-2xl" aria-roledescription="carousel" aria-label="Featured work" data-peek>
<div class="mb-3 flex justify-end gap-1.5">
<button class="grid h-9 w-9 place-items-center rounded-full border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 dark:hover:text-white dark:focus-visible:ring-blue-400 dark:focus-visible:ring-offset-gray-950" type="button" aria-label="Previous slide" data-peek-prev>
<svg class="h-[1.125rem] w-[1.125rem]" 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="grid h-9 w-9 place-items-center rounded-full border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 dark:hover:text-white dark:focus-visible:ring-blue-400 dark:focus-visible:ring-offset-gray-950" type="button" aria-label="Next slide" data-peek-next>
<svg class="h-[1.125rem] w-[1.125rem]" 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 class="flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-smooth pb-2 [scrollbar-width:none] focus-visible:rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 motion-reduce:scroll-auto dark:focus-visible:ring-blue-400 [&::-webkit-scrollbar]:hidden" role="group" aria-label="Slides, scrollable" tabindex="0" data-peek-track>
<article class="shrink-0 basis-[85%] snap-center overflow-hidden rounded-xl border border-gray-200 bg-white sm:basis-[70%] dark:border-gray-800 dark:bg-gray-950" role="group" aria-roledescription="slide" aria-label="1 of 3">
<div class="aspect-[16/9] w-full bg-gradient-to-br from-blue-600 to-indigo-600"></div>
<div class="p-5"><h3 class="mb-1 text-base font-semibold text-gray-900 dark:text-gray-100">Analytics</h3><p class="text-sm leading-relaxed text-gray-600 dark:text-gray-300">Every metric that matters, live.</p></div>
</article>
<article class="shrink-0 basis-[85%] snap-center overflow-hidden rounded-xl border border-gray-200 bg-white sm:basis-[70%] dark:border-gray-800 dark:bg-gray-950" role="group" aria-roledescription="slide" aria-label="2 of 3">
<div class="aspect-[16/9] w-full bg-gradient-to-br from-indigo-600 to-violet-600"></div>
<div class="p-5"><h3 class="mb-1 text-base font-semibold text-gray-900 dark:text-gray-100">Automations</h3><p class="text-sm leading-relaxed text-gray-600 dark:text-gray-300">Turn a repeated click into a rule.</p></div>
</article>
<article class="shrink-0 basis-[85%] snap-center overflow-hidden rounded-xl border border-gray-200 bg-white sm:basis-[70%] dark:border-gray-800 dark:bg-gray-950" role="group" aria-roledescription="slide" aria-label="3 of 3">
<div class="aspect-[16/9] w-full bg-gradient-to-br from-teal-700 to-sky-700"></div>
<div class="p-5"><h3 class="mb-1 text-base font-semibold text-gray-900 dark:text-gray-100">Integrations</h3><p class="text-sm leading-relaxed text-gray-600 dark:text-gray-300">Forty connectors and a webhook.</p></div>
</article>
</div>
</section>
<script>
(function () {
document.querySelectorAll('[data-peek]').forEach(function (root) {
var track = root.querySelector('[data-peek-track]');
function step(dir) {
var slide = track.firstElementChild;
if (!slide) return;
var gap = parseFloat(getComputedStyle(track).columnGap || '0') || 0;
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
track.scrollBy({ left: dir * (slide.getBoundingClientRect().width + gap), behavior: reduced ? 'auto' : 'smooth' });
}
root.querySelector('[data-peek-prev]').addEventListener('click', function () { step(-1); });
root.querySelector('[data-peek-next]').addEventListener('click', function () { step(1); });
});
})();
</script>| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | PeekCard[] | - | The array of items to render. |
className | string | - | Additional classes merged onto the root element. |
ariaLabel | string | 'Carousel' | The button's accessible name. Required for icon-only buttons. |
Cards are 85% wide (70% from `sm`) and `snap-center`, so a neighbour always shows at each edge as a "there is more" cue. Scroll-snap does the paging; the arrows nudge by one card width read live from the DOM, so widening the cards needs no code change.