A carousel presents a series of slides that users can scroll through horizontally or vertically. It uses native CSS Scroll Snap for smooth, hardware-accelerated scrolling without JavaScript animation loops.
Features
- Native CSS Scroll Snap for smooth scrolling
- Horizontal and vertical orientations
- Slide alignment:
start, center, or end
- Multiple slides visible per page
- Configurable slides per move
- Loop from last slide back to first
- Autoplay with configurable delay
- Mouse drag support
- Indicator dots with active state
- Variable-width slides with
autoSize
Installation
npm install @zag-js/carousel @zag-js/react
The slideCount prop is required. Pass the total number of slides so the machine can compute snap points and enable or disable navigation triggers.
import * as carousel from "@zag-js/carousel"
import { normalizeProps, useMachine } from "@zag-js/react"
import { useId } from "react"
const slides = [
{ src: "/images/slide-1.jpg", alt: "Slide 1" },
{ src: "/images/slide-2.jpg", alt: "Slide 2" },
{ src: "/images/slide-3.jpg", alt: "Slide 3" },
]
export function Carousel() {
const service = useMachine(carousel.machine, {
id: useId(),
slideCount: slides.length,
})
const api = carousel.connect(service, normalizeProps)
return (
<div {...api.getRootProps()}>
<div {...api.getItemGroupProps()}>
{slides.map((slide, index) => (
<div key={index} {...api.getItemProps({ index })}>
<img src={slide.src} alt={slide.alt} />
</div>
))}
</div>
<div {...api.getControlProps()}>
<button {...api.getPrevTriggerProps()}>Previous</button>
<div {...api.getIndicatorGroupProps()}>
{slides.map((_, index) => (
<button key={index} {...api.getIndicatorProps({ index })}>
{index + 1}
</button>
))}
</div>
<button {...api.getNextTriggerProps()}>Next</button>
</div>
</div>
)
}
<script setup>
import * as carousel from "@zag-js/carousel"
import { normalizeProps, useMachine } from "@zag-js/vue"
import { computed } from "vue"
const slides = [
{ src: "/images/slide-1.jpg", alt: "Slide 1" },
{ src: "/images/slide-2.jpg", alt: "Slide 2" },
{ src: "/images/slide-3.jpg", alt: "Slide 3" },
]
const service = useMachine(carousel.machine, {
id: "carousel-1",
slideCount: slides.length,
})
const api = computed(() => carousel.connect(service, normalizeProps))
</script>
<template>
<div v-bind="api.getRootProps()">
<div v-bind="api.getItemGroupProps()">
<div
v-for="(slide, index) in slides"
:key="index"
v-bind="api.getItemProps({ index })"
>
<img :src="slide.src" :alt="slide.alt" />
</div>
</div>
<div v-bind="api.getControlProps()">
<button v-bind="api.getPrevTriggerProps()">Previous</button>
<button v-bind="api.getNextTriggerProps()">Next</button>
</div>
</div>
</template>
import * as carousel from "@zag-js/carousel"
import { normalizeProps, useMachine } from "@zag-js/solid"
import { createMemo, createUniqueId, For } from "solid-js"
const slides = [
{ src: "/images/slide-1.jpg", alt: "Slide 1" },
{ src: "/images/slide-2.jpg", alt: "Slide 2" },
{ src: "/images/slide-3.jpg", alt: "Slide 3" },
]
export function Carousel() {
const service = useMachine(carousel.machine, {
id: createUniqueId(),
slideCount: slides.length,
})
const api = createMemo(() => carousel.connect(service, normalizeProps))
return (
<div {...api().getRootProps()}>
<div {...api().getItemGroupProps()}>
<For each={slides}>
{(slide, index) => (
<div {...api().getItemProps({ index: index() })}>
<img src={slide.src} alt={slide.alt} />
</div>
)}
</For>
</div>
<div {...api().getControlProps()}>
<button {...api().getPrevTriggerProps()}>Previous</button>
<button {...api().getNextTriggerProps()}>Next</button>
</div>
</div>
)
}
Orientation
Set orientation to "vertical" for a vertical scrolling carousel:
const service = useMachine(carousel.machine, {
slideCount: 5,
orientation: "vertical",
})
Multiple slides per page
Use slidesPerPage to show more than one slide at a time, and slidesPerMove to control how many slides advance per navigation action.
const service = useMachine(carousel.machine, {
slideCount: 9,
slidesPerPage: 3,
slidesPerMove: 1,
})
slidesPerMove must be less than or equal to slidesPerPage to avoid skipping slides. Setting it to "auto" advances by the same number as slidesPerPage.
Slide alignment
Pass snapAlign to each slide item to control how it aligns to the scroll snap container:
<div {...api.getItemProps({ index, snapAlign: "center" })}>
{/* slide content */}
</div>
Accepted values: "start" (default), "center", "end".
Looping
Set loop to true to wrap navigation from the last page back to the first:
const service = useMachine(carousel.machine, {
slideCount: 5,
loop: true,
})
Autoplay
Pass autoplay: true to start automatic slide advancement. The default delay is 4000 ms. Pass { delay: number } to customize it:
// Default 4 second delay
const service = useMachine(carousel.machine, {
slideCount: 5,
autoplay: true,
})
// Custom 2 second delay
const service = useMachine(carousel.machine, {
slideCount: 5,
autoplay: { delay: 2000 },
})
Render an autoplay toggle button with getAutoplayTriggerProps:
<button {...api.getAutoplayTriggerProps()}>
{api.isPlaying ? "Pause" : "Play"}
</button>
Use api.play() and api.pause() to control autoplay programmatically.
Mouse drag
Enable drag-to-scroll with a mouse pointer:
const service = useMachine(carousel.machine, {
slideCount: 5,
allowMouseDrag: true,
})
Spacing and padding
Control the gap between slides with spacing, and add viewport padding to keep neighboring slides partially visible:
const service = useMachine(carousel.machine, {
slideCount: 5,
spacing: "16px",
padding: "32px",
})
Variable-width slides
Set autoSize: true when slides have different widths:
const service = useMachine(carousel.machine, {
slideCount: 5,
autoSize: true,
})
Controlled page
Use page and onPageChange to control the current page externally:
const [page, setPage] = useState(0)
const service = useMachine(carousel.machine, {
slideCount: 5,
page,
onPageChange(details) {
setPage(details.page)
},
})
Progress text
Render accessible progress text with getProgressTextProps:
<div {...api.getProgressTextProps()}>{api.getProgressText()}</div>
Customize the message with translations.progressText:
const service = useMachine(carousel.machine, {
slideCount: 5,
translations: {
progressText: ({ page, totalPages }) => `Page ${page + 1} of ${totalPages}`,
nextTrigger: "Next slide",
prevTrigger: "Previous slide",
indicator: (index) => `Go to slide ${index + 1}`,
autoplayStart: "Start autoplay",
autoplayStop: "Stop autoplay",
},
})
The total number of slides. Required for computing snap points.
Number of slides visible at one time. Defaults to 1.
Number of slides to advance per navigation action. Defaults to "auto" (same as slidesPerPage).
orientation
"horizontal" | "vertical"
Scroll direction of the carousel. Defaults to "horizontal".
Wrap navigation from the last page back to the first. Defaults to false.
autoplay
boolean | { delay: number }
Enable automatic slide advancement. Pass { delay } to set a custom interval in milliseconds. Defaults to false.
Enable drag-to-scroll using a mouse pointer. Defaults to false.
CSS gap between slides (e.g. "16px"). Defaults to "0px".
Extra space around the scroll area to keep neighboring slides partially visible.
Enable support for variable-width slides. Defaults to false.
The initial page index when uncontrolled. Defaults to 0.
The controlled current page index.
onPageChange
(details: { page: number }) => void
Called when the visible page changes.
onDragStatusChange
(details: DragStatusDetails) => void
Called when dragging starts, progresses, or ends.
onAutoplayStatusChange
(details: AutoplayStatusDetails) => void
Called when autoplay starts or stops.
snapType
"mandatory" | "proximity"
CSS scroll snap type. Defaults to "mandatory".
Localized labels for triggers, indicators, and progress text.
Styling
Every part exposes a data-part attribute for CSS targeting.
Parts reference
| Part | Element | Description |
|---|
root | div | The root container |
itemGroup | div | The scroll container holding all slides |
item | div | An individual slide |
control | div | Container for navigation controls |
prevTrigger | button | Scrolls to the previous page |
nextTrigger | button | Scrolls to the next page |
indicatorGroup | div | Container for indicator dots |
indicator | button | An individual indicator dot |
autoplayTrigger | button | Toggles autoplay |
progressText | div | Accessible progress text |
Active indicator
When a carousel indicator corresponds to the current page, data-current is set on the indicator:
[data-part="indicator"][data-current] {
background: currentColor;
}
Disabled navigation
When scrolling is not possible (e.g. already at the first or last page without looping), data-disabled is set on the trigger:
[data-part="prev-trigger"][data-disabled] {
opacity: 0.4;
pointer-events: none;
}
Full parts example
[data-part="root"] { overflow: hidden; }
[data-part="item-group"] { display: flex; }
[data-part="item"] { flex-shrink: 0; }
[data-part="control"] { display: flex; align-items: center; gap: 8px; }
[data-part="indicator-group"] { display: flex; gap: 4px; }
[data-part="indicator"] { width: 8px; height: 8px; border-radius: 50%; }
[data-part="indicator"][data-current] { background: blue; }
Accessibility
The carousel follows ARIA authoring practices with role="region" on the root and aria-label on navigation buttons and indicator dots. All accessible labels are configurable through the translations prop.
Keyboard interactions
| Key | Description |
|---|
Tab | Move focus between interactive controls |
Enter / Space | Activate the focused button (next, prev, indicator, autoplay) |
Arrow keys | Navigate between indicator buttons within the indicator group |