Skip to main content
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

Usage

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>
  )
}

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",
  },
})

Props

slideCount
number
required
The total number of slides. Required for computing snap points.
slidesPerPage
number
Number of slides visible at one time. Defaults to 1.
slidesPerMove
number | "auto"
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".
loop
boolean
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.
allowMouseDrag
boolean
Enable drag-to-scroll using a mouse pointer. Defaults to false.
spacing
string
CSS gap between slides (e.g. "16px"). Defaults to "0px".
padding
string
Extra space around the scroll area to keep neighboring slides partially visible.
autoSize
boolean
Enable support for variable-width slides. Defaults to false.
defaultPage
number
The initial page index when uncontrolled. Defaults to 0.
page
number
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".
translations
IntlTranslations
Localized labels for triggers, indicators, and progress text.

Styling

Every part exposes a data-part attribute for CSS targeting.

Parts reference

PartElementDescription
rootdivThe root container
itemGroupdivThe scroll container holding all slides
itemdivAn individual slide
controldivContainer for navigation controls
prevTriggerbuttonScrolls to the previous page
nextTriggerbuttonScrolls to the next page
indicatorGroupdivContainer for indicator dots
indicatorbuttonAn individual indicator dot
autoplayTriggerbuttonToggles autoplay
progressTextdivAccessible 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

KeyDescription
TabMove focus between interactive controls
Enter / SpaceActivate the focused button (next, prev, indicator, autoplay)
Arrow keysNavigate between indicator buttons within the indicator group

Build docs developers (and LLMs) love