Carousel
A scroll-snapping slideshow with keyboard, button, and optional drag navigation.
@data-slot/carouselSource ↗bun add @data-slot/carouselnpm install @data-slot/carouselpnpm add @data-slot/carouselAnatomy
The content element is a native scroll container and every direct carousel-item child is a slide. Style the content with scroll-snap-type and size each slide to fill it; the component reads the resulting layout rather than imposing one. The previous and next controls are optional.
<div data-slot="carousel" data-default-index="0">
<div data-slot="carousel-content">
<div data-slot="carousel-item">Slide 1</div>
<div data-slot="carousel-item">Slide 2</div>
<div data-slot="carousel-item">Slide 3</div>
</div>
<button data-slot="carousel-previous">Previous</button>
<button data-slot="carousel-next">Next</button>
</div>
The active slide is the one the scroll position rests on. Navigation scrolls smoothly, or instantly when the visitor prefers reduced motion, and the index follows once scrolling settles. Inactive slides are inert and aria-hidden, so the layout should show one slide at a time.
Examples
Navigate with buttons and keys
Arrow keys move between slides while focus is inside the carousel, and Home and End jump to the first and last slide. data-loop lets the controls wrap around. The dots below the slides are demo markup: each one dispatches carousel:set with its index, and they follow carousel:change to highlight the active slide.
Show code
<div data-slot="carousel" data-loop class="carousel">
<div data-slot="carousel-content" class="carousel-content">
<div data-slot="carousel-item" class="carousel-item">01</div>
<div data-slot="carousel-item" class="carousel-item">02</div>
<div data-slot="carousel-item" class="carousel-item">03</div>
<div data-slot="carousel-item" class="carousel-item">04</div>
<div data-slot="carousel-item" class="carousel-item">05</div>
</div>
<div class="carousel-controls">
<div class="carousel-nav-group">
<button data-slot="carousel-previous" class="carousel-nav" aria-label="Previous slide">
<
</button>
<button data-slot="carousel-next" class="carousel-nav" aria-label="Next slide">
>
</button>
</div>
<div class="carousel-dots">
<button data-slot="carousel-dot" data-state="active" class="carousel-dot" type="button" aria-label="Go to slide 1"></button>
<button data-slot="carousel-dot" data-state="inactive" class="carousel-dot" type="button" aria-label="Go to slide 2"></button>
<button data-slot="carousel-dot" data-state="inactive" class="carousel-dot" type="button" aria-label="Go to slide 3"></button>
<button data-slot="carousel-dot" data-state="inactive" class="carousel-dot" type="button" aria-label="Go to slide 4"></button>
<button data-slot="carousel-dot" data-state="inactive" class="carousel-dot" type="button" aria-label="Go to slide 5"></button>
</div>
</div>
</div>
<style>
.carousel {
width: 100%;
max-width: 56rem;
margin-inline: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
color: var(--text);
}
.carousel-content {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
border: 1px solid var(--border);
border-radius: 0;
background: var(--code-bg);
}
.carousel-content::-webkit-scrollbar {
display: none;
}
.carousel-item {
flex: 0 0 100%;
min-width: 0;
scroll-snap-align: start;
height: 21rem;
display: grid;
place-items: center;
color: var(--text);
font-size: 3.75rem;
font-weight: 500;
line-height: 1;
letter-spacing: 0.2em;
text-indent: 0.2em;
}
.carousel-controls {
margin-top: 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.carousel-nav-group {
display: flex;
align-items: center;
gap: 0.75rem;
}
.carousel-nav {
display: grid;
place-items: center;
touch-action: manipulation;
padding: 0;
width: 3rem;
height: 3rem;
border-radius: 0;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
transition: border-color 180ms ease, background-color 180ms ease;
}
.carousel-nav:hover {
border-color: var(--text);
background: var(--code-bg);
}
.carousel-nav:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.carousel-dots {
display: flex;
align-items: center;
gap: 0.75rem;
}
.carousel-dot {
padding: 0;
width: 0.75rem;
height: 0.75rem;
border-radius: 9999px;
border: 1px solid var(--border);
background: var(--surface);
cursor: pointer;
transition: border-color 180ms ease, background-color 180ms ease;
}
.carousel-dot[data-state="active"] {
border-color: var(--text);
background: var(--text);
}
@media (width < 40rem) {
.carousel-item {
height: 14rem;
font-size: 2.5rem;
}
.carousel-controls {
margin-top: 1rem;
}
.carousel-nav {
width: 2.5rem;
height: 2.5rem;
font-size: 1.1rem;
}
.carousel-dots {
gap: 0.5rem;
}
}
</style><div data-slot="carousel" data-loop class="mx-auto w-full max-w-4xl font-mono text-[var(--text)]">
<div
data-slot="carousel-content"
class="flex overflow-x-auto snap-x snap-mandatory rounded-none border border-[var(--border)] bg-[var(--code-bg)] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
01
</div>
<div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
02
</div>
<div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
03
</div>
<div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
04
</div>
<div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
05
</div>
</div>
<div class="mt-4 flex items-center justify-between gap-4 sm:mt-5">
<div class="flex items-center gap-3">
<button
data-slot="carousel-previous"
class="grid h-10 w-10 touch-manipulation place-items-center rounded-none border border-[var(--border)] bg-[var(--surface)] p-0 text-[1.1rem] leading-none cursor-pointer transition-colors duration-[180ms] ease-[ease] sm:h-12 sm:w-12 sm:text-xl sm:leading-none hover:border-[var(--text)] hover:bg-[var(--code-bg)] disabled:cursor-not-allowed disabled:opacity-45"
aria-label="Previous slide"
>
<
</button>
<button
data-slot="carousel-next"
class="grid h-10 w-10 touch-manipulation place-items-center rounded-none border border-[var(--border)] bg-[var(--surface)] p-0 text-[1.1rem] leading-none cursor-pointer transition-colors duration-[180ms] ease-[ease] sm:h-12 sm:w-12 sm:text-xl sm:leading-none hover:border-[var(--text)] hover:bg-[var(--code-bg)] disabled:cursor-not-allowed disabled:opacity-45"
aria-label="Next slide"
>
>
</button>
</div>
<div class="flex items-center gap-2 sm:gap-3">
<button data-slot="carousel-dot" data-state="active" type="button" aria-label="Go to slide 1" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
<button data-slot="carousel-dot" data-state="inactive" type="button" aria-label="Go to slide 2" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
<button data-slot="carousel-dot" data-state="inactive" type="button" aria-label="Go to slide 3" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
<button data-slot="carousel-dot" data-state="inactive" type="button" aria-label="Go to slide 4" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
<button data-slot="carousel-dot" data-state="inactive" type="button" aria-label="Go to slide 5" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
</div>
</div>
</div>import { create } from "@data-slot/carousel";
function bindCarouselExample(root, signal) {
const dots = Array.from(root.querySelectorAll('[data-slot="carousel-dot"]'));
const sync = (index) => dots.forEach((dot, i) => {
dot.setAttribute('data-state', i === index ? 'active' : 'inactive');
});
dots.forEach((dot, index) => {
dot.addEventListener('click', () => {
root.dispatchEvent(new CustomEvent('carousel:set', { detail: { index } }));
}, { signal });
});
root.addEventListener('carousel:change', event => sync(event.detail.index), { signal });
sync(Number(root.dataset.index));
}
// Initialize after the markup is in the document.
const abort = new AbortController();
const controllers = create();
document.querySelectorAll('[data-slot="carousel"]').forEach(root => {
bindCarouselExample(root, abort.signal);
});
// Clean up before removing the example.
// abort.abort();
// controllers.forEach(controller => controller.destroy());Drag to navigate
Add data-drag to let people drag or swipe the slides. Native scroll snapping pauses while a drag is in progress and the carousel settles on the nearest slide when the pointer lifts. Presses on links, buttons, and form controls inside a slide never start a drag.
Show code
<div data-slot="carousel" data-drag data-loop class="carousel">
<div data-slot="carousel-content" class="carousel-content">
<div data-slot="carousel-item" class="carousel-item">01</div>
<div data-slot="carousel-item" class="carousel-item">02</div>
<div data-slot="carousel-item" class="carousel-item">03</div>
<div data-slot="carousel-item" class="carousel-item">04</div>
<div data-slot="carousel-item" class="carousel-item">05</div>
</div>
<div class="carousel-controls">
<div class="carousel-nav-group">
<button data-slot="carousel-previous" class="carousel-nav" aria-label="Previous slide">
<
</button>
<button data-slot="carousel-next" class="carousel-nav" aria-label="Next slide">
>
</button>
</div>
<div class="carousel-dots">
<button data-slot="carousel-dot" data-state="active" class="carousel-dot" type="button" aria-label="Go to slide 1"></button>
<button data-slot="carousel-dot" data-state="inactive" class="carousel-dot" type="button" aria-label="Go to slide 2"></button>
<button data-slot="carousel-dot" data-state="inactive" class="carousel-dot" type="button" aria-label="Go to slide 3"></button>
<button data-slot="carousel-dot" data-state="inactive" class="carousel-dot" type="button" aria-label="Go to slide 4"></button>
<button data-slot="carousel-dot" data-state="inactive" class="carousel-dot" type="button" aria-label="Go to slide 5"></button>
</div>
</div>
</div>
<style>
.carousel {
width: 100%;
max-width: 56rem;
margin-inline: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
color: var(--text);
}
.carousel-content {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
border: 1px solid var(--border);
border-radius: 0;
background: var(--code-bg);
}
.carousel-content::-webkit-scrollbar {
display: none;
}
.carousel-item {
flex: 0 0 100%;
min-width: 0;
scroll-snap-align: start;
height: 21rem;
display: grid;
place-items: center;
color: var(--text);
font-size: 3.75rem;
font-weight: 500;
line-height: 1;
letter-spacing: 0.2em;
text-indent: 0.2em;
}
.carousel-controls {
margin-top: 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.carousel-nav-group {
display: flex;
align-items: center;
gap: 0.75rem;
}
.carousel-nav {
display: grid;
place-items: center;
touch-action: manipulation;
padding: 0;
width: 3rem;
height: 3rem;
border-radius: 0;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
transition: border-color 180ms ease, background-color 180ms ease;
}
.carousel-nav:hover {
border-color: var(--text);
background: var(--code-bg);
}
.carousel-nav:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.carousel-dots {
display: flex;
align-items: center;
gap: 0.75rem;
}
.carousel-dot {
padding: 0;
width: 0.75rem;
height: 0.75rem;
border-radius: 9999px;
border: 1px solid var(--border);
background: var(--surface);
cursor: pointer;
transition: border-color 180ms ease, background-color 180ms ease;
}
.carousel-dot[data-state="active"] {
border-color: var(--text);
background: var(--text);
}
@media (width < 40rem) {
.carousel-item {
height: 14rem;
font-size: 2.5rem;
}
.carousel-controls {
margin-top: 1rem;
}
.carousel-nav {
width: 2.5rem;
height: 2.5rem;
font-size: 1.1rem;
}
.carousel-dots {
gap: 0.5rem;
}
}
</style><div data-slot="carousel" data-drag data-loop class="mx-auto w-full max-w-4xl font-mono text-[var(--text)]">
<div
data-slot="carousel-content"
class="flex overflow-x-auto snap-x snap-mandatory rounded-none border border-[var(--border)] bg-[var(--code-bg)] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
01
</div>
<div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
02
</div>
<div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
03
</div>
<div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
04
</div>
<div data-slot="carousel-item" class="grid h-56 min-w-0 shrink-0 grow-0 basis-full place-items-center snap-start text-[2.5rem] leading-none font-medium tracking-[0.2em] text-[var(--text)] [text-indent:0.2em] sm:h-84 sm:text-6xl">
05
</div>
</div>
<div class="mt-4 flex items-center justify-between gap-4 sm:mt-5">
<div class="flex items-center gap-3">
<button
data-slot="carousel-previous"
class="grid h-10 w-10 touch-manipulation place-items-center rounded-none border border-[var(--border)] bg-[var(--surface)] p-0 text-[1.1rem] leading-none cursor-pointer transition-colors duration-[180ms] ease-[ease] sm:h-12 sm:w-12 sm:text-xl sm:leading-none hover:border-[var(--text)] hover:bg-[var(--code-bg)] disabled:cursor-not-allowed disabled:opacity-45"
aria-label="Previous slide"
>
<
</button>
<button
data-slot="carousel-next"
class="grid h-10 w-10 touch-manipulation place-items-center rounded-none border border-[var(--border)] bg-[var(--surface)] p-0 text-[1.1rem] leading-none cursor-pointer transition-colors duration-[180ms] ease-[ease] sm:h-12 sm:w-12 sm:text-xl sm:leading-none hover:border-[var(--text)] hover:bg-[var(--code-bg)] disabled:cursor-not-allowed disabled:opacity-45"
aria-label="Next slide"
>
>
</button>
</div>
<div class="flex items-center gap-2 sm:gap-3">
<button data-slot="carousel-dot" data-state="active" type="button" aria-label="Go to slide 1" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
<button data-slot="carousel-dot" data-state="inactive" type="button" aria-label="Go to slide 2" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
<button data-slot="carousel-dot" data-state="inactive" type="button" aria-label="Go to slide 3" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
<button data-slot="carousel-dot" data-state="inactive" type="button" aria-label="Go to slide 4" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
<button data-slot="carousel-dot" data-state="inactive" type="button" aria-label="Go to slide 5" class="h-3 w-3 rounded-full border border-[var(--border)] bg-[var(--surface)] p-0 cursor-pointer transition-colors duration-[180ms] ease-[ease] data-[state=active]:border-[var(--text)] data-[state=active]:bg-[var(--text)]"></button>
</div>
</div>
</div>import { create } from "@data-slot/carousel";
function bindCarouselExample(root, signal) {
const dots = Array.from(root.querySelectorAll('[data-slot="carousel-dot"]'));
const sync = (index) => dots.forEach((dot, i) => {
dot.setAttribute('data-state', i === index ? 'active' : 'inactive');
});
dots.forEach((dot, index) => {
dot.addEventListener('click', () => {
root.dispatchEvent(new CustomEvent('carousel:set', { detail: { index } }));
}, { signal });
});
root.addEventListener('carousel:change', event => sync(event.detail.index), { signal });
sync(Number(root.dataset.index));
}
// Initialize after the markup is in the document.
const abort = new AbortController();
const controllers = create();
document.querySelectorAll('[data-slot="carousel"]').forEach(root => {
bindCarouselExample(root, abort.signal);
});
// Clean up before removing the example.
// abort.abort();
// controllers.forEach(controller => controller.destroy());API reference
create(scope?)
Auto-discover and bind all carousel roots in a scope (document by default).
import { create } from "@data-slot/carousel";
const controllers = create(); // CarouselController[]
createCarousel(root, options?)
Create a controller for a specific root element.
import { createCarousel } from "@data-slot/carousel";
const carousel = createCarousel(element, {
defaultIndex: 1,
orientation: "horizontal",
drag: true,
loop: false,
onIndexChange: (index) => console.log(index),
});
Data Attributes
JS options take precedence over data attributes.
| Attribute | Type | Default | Description |
|---|---|---|---|
data-default-index |
number | 0 |
Initial active index |
data-orientation |
horizontal | vertical |
horizontal |
Carousel orientation |
data-drag |
boolean | false |
Enable pointer drag/swipe navigation |
data-loop |
boolean | false |
Enable soft-wrap loop navigation |
Options
| Option | Type | Default | Description |
|---|---|---|---|
defaultIndex |
number |
0 |
Initial active slide index |
orientation |
"horizontal" | "vertical" |
"horizontal" |
Axis used for keyboard navigation and scrolling |
drag |
boolean |
false |
Enable pointer drag/swipe navigation on the scroll container |
loop |
boolean |
false |
Enable soft-wrap for prev/next/keyboard/API navigation |
onIndexChange |
(index: number) => void |
undefined |
Called when active slide changes |
Controller
| Method / Property | Description |
|---|---|
prev() |
Navigate to previous slide |
next() |
Navigate to next slide |
goTo(index) |
Navigate to specific slide |
index |
Current active index |
count |
Total number of slides |
canScrollPrev |
Whether previous navigation is available |
canScrollNext |
Whether next navigation is available |
destroy() |
Cleanup listeners and observers |
Navigation Animation
Carousel navigation uses native smooth scrolling by default for:
prev()/next()/goTo()- keyboard navigation (
Arrow*,Home,End) - inbound
carousel:setevents - optional
carousel-previous/carousel-nextbutton clicks
When the user prefers reduced motion (prefers-reduced-motion: reduce), navigation falls back to instant scroll behavior.
When drag is enabled, the carousel also supports pointer drag/swipe gestures and snaps to the nearest slide on release.
Events
Outbound (on root)
| Event | Detail | Description |
|---|---|---|
carousel:change |
{ index: number } |
Fires when active index changes |
Inbound (on root)
| Event | Detail | Description |
|---|---|---|
carousel:set |
{ index?: number, action?: "next" | "prev" } |
Programmatically navigate carousel |
root.addEventListener("carousel:change", (event) => {
console.log(event.detail.index);
});
root.dispatchEvent(
new CustomEvent("carousel:set", { detail: { action: "next" } }),
);
root.dispatchEvent(
new CustomEvent("carousel:set", { detail: { index: 2 } }),
);
Required Slots
carousel(root)carousel-content(scroll container)carousel-item(direct slide children)
Optional Slots
carousel-previouscarousel-next
Styling
The component is unstyled and relies on CSS hooks:
[data-slot="carousel"] { position: relative; }
[data-slot="carousel-content"] {
display: flex;
overflow: auto;
scroll-snap-type: x mandatory;
}
[data-slot="carousel"][data-dragging="true"] {
cursor: grabbing;
}
[data-slot="carousel-item"] {
flex: 0 0 100%;
scroll-snap-align: start;
}
[data-slot="carousel-item"][data-state="active"] {
opacity: 1;
}
[data-slot="carousel-item"][data-state="inactive"] {
opacity: 0.75;
}
For vertical carousels, switch scroll-snap-type to y mandatory and use column layout.
Accessibility
The carousel shows one slide at a time. Inactive slides are marked aria-hidden and inert, which removes them and their content from the accessibility tree and the tab order until they become active. Size slides to fill the viewport, as in the styling example above; a layout that shows several slides at once would hide visible neighbours from assistive technology.
The controller automatically sets:
- root:
role="region",aria-roledescription="carousel" - item:
role="group",aria-roledescription="slide",aria-label="n of total" - item state:
data-state,aria-hidden,inert - root drag state:
data-dragging="true"during an active pointer drag - nav controls:
disabled/aria-disabledsynced to scrollability