Carousel
Introduction
A slideshow that pages through a set of slides using real, native CSS
scroll-snap — not transform math. Supports paging by group (slidesPerPage),
looping, autoplay, mouse-drag scrolling, keyboard navigation, and vertical
orientation.
Usage
Basic carousel
import { Carousel } from "../components/ui";
export default function Page() {
return (
<Carousel
slides={[
<div>Slide 1</div>,
<div>Slide 2</div>,
<div>Slide 3</div>,
]}
/>
);
}
Multiple slides per page, looping
<Carousel
slides={slides}
slidesPerPage={3}
spacing="16px"
loop
colorPalette="purple"
/>
Autoplay with pause-on-hover
<Carousel
slides={slides}
autoplay={{ delay: 2500 }}
pauseOnHover
loop
showAutoplayTrigger
/>
Vertical orientation
<div class={css({ height: "72" })}>
<Carousel slides={slides} orientation="vertical" class={css({ height: "full" })} />
</div>
Overlaid (inline) controls
<Carousel slides={slides} inline loop />
CMS Page Builder
This component is available as a carousel block in the Page Builder (content/pages/*.json). Slides are flat { image, caption, href } records, not nested component blocks:
{
"type": "carousel",
"slides": [
{ "image": "/hero-1.jpg", "caption": "Slide 1" },
{ "image": "/hero-2.jpg", "caption": "Slide 2" }
],
"loop": true,
"autoplayDelay": 3000
}
Props
Carousel
The data-driven convenience component: pass slides and it composes
ItemGroup/Item plus a default Control (prev/next triggers and
indicator dots) for you. For full manual composition, use Carousel.Root
and the exported parts directly (see Manual composition).
| Prop | Type | Description | Default |
|---|---|---|---|
slides | JSX.Element[] | The slide contents. slideCount is derived from its length. | - |
interactive | boolean | Forces hydration as an island. | true |
page | number | Whether the carousel is on a given page (controlled). | - |
defaultPage | number | Initial page (uncontrolled). | 0 |
slidesPerPage | number | Number of slides visible at once. | 1 |
| slidesPerMove | `number \ | "auto"` | Slides advanced per page step. "auto" uses slidesPerPage. | "auto" |
| orientation | `"horizontal" \ | "vertical"` | Scroll axis. | "horizontal" |
| loop | boolean | Wraps around at the first/last page. | false |
| spacing | string | Gap between slides (any CSS length). | "0px" |
| padding | string | Extra scroll padding at each end (any CSS length). | - |
| autoSize | boolean | Lets slides size themselves instead of splitting evenly by slidesPerPage. | false |
| allowMouseDrag | boolean | Enables click-and-drag scrolling with the mouse (touch/trackpad scrolling always works natively). | false |
| autoplay | `boolean \ | { delay: number }` | Auto-advances pages. Always wraps at the end regardless of loop. | false |
| pauseOnHover | boolean | Pauses autoplay while the pointer is over the carousel. | false |
| snapType | `"proximity" \ | "mandatory"` | CSS scroll-snap strictness. | "mandatory" |
| disabled | boolean | Disables all triggers/indicators and dragging. | false |
| showControls | boolean | Renders PrevTrigger/NextTrigger in the default Control. | true |
| showIndicators | boolean | Renders an IndicatorGroup in the default Control. | true |
| showAutoplayTrigger | boolean | Renders an AutoplayTrigger in the default Control. | false |
| itemClass | string | Custom class applied to every generated Item. | - |
| size | `"sm" \ | "md" \ | "lg"` | Size of the triggers/indicators. | "md" |
| colorPalette | `"gray" \ | "blue" \ | "cyan" \ | "green" \ | "orange" \ | "purple" \ | "red" \ | "teal" \ | "indigo" \ | "pink" \ | "yellow" \ | "success" \ | "error" \ | "warning"` | Accent color for the active indicator/pressed autoplay trigger. | "green" |
| inline | boolean | Overlays Control on top of the item group instead of stacking below it. | false |
| translations | CarouselTranslations | Localised strings (aria-labels, progress text). | - |
| onPageChange | (details: { page: number; pageSnapPoint: number }) => void | Called when the active page settles. | - |
| onAutoplayStatusChange | (details: { type: string; isPlaying: boolean; page: number }) => void | Called when autoplay starts/ticks/stops. | - |
| onDragStatusChange | (details: { type: string; isDragging: boolean; page: number }) => void | Called on drag start/move/end. | - |
| class | string | Custom CSS classes for the root element. | - |
| classNames | Record<string, string> | Custom CSS classes per part (root, itemGroup, item, control, prevTrigger, nextTrigger, indicatorGroup, indicator, autoplayTrigger). | - |
Carousel.Item
| Prop | Type | Description | Default |
|---|---|---|---|
index | number | The slide's position. Required. | - |
| snapAlign | `"start" \ | "center" \ | "end"` | Which edge of the slide snaps into view. | "start" |
Carousel.Indicator
| Prop | Type | Description | Default |
|---|---|---|---|
index | number | The page it jumps to. Required. | - |
readOnly | boolean | Renders the dot without a click handler. | false |
Architecture notes
- Native scrolling, not transforms.
ItemGroupis a realoverflow: autogrid/flex container withscroll-snap-type; paging callsscrollTo(...)on it. This means touch swipe, trackpad scroll, and focused-element arrow-key scrolling work even before hydration finishes — only the trigger/indicator clicks and autoplay require JavaScript. pageSnapPointsis the item index each page starts at, computed with the same structural formula Ark UI's@zag-js/carouselmachine uses (getPageSnapPoints) — deterministic fromslideCount/slidesPerPage/slidesPerMovealone, so it's identical on the server and before hydration (no layout measurement needed for SSR).- Children don't re-render on hydration. Like this project's other
islands, the interactive
Carouselroot patchesdata-current,disabled,data-inview, andaria-hiddenon the already-rendered DOM directly (seeapplyPageStateincarousel-primitive.tsx) rather than regeneratingItem/Indicatorchildren — HonoX rehydrates an island's children from a serialised HTML snapshot, not by re-invoking the component that produced them. - Never rely on a JSX
onClickfor a trigger/indicator button. For the same reason:PrevTrigger/NextTrigger/Indicator/AutoplayTriggerare composed as children, so hono/jsx/dom never reconciles its own synthetic event props onto those already-mounted nodes — anonClickon them silently never fires. All click handling is delegated from the root in a singleuseEffect(target.closest('[data-part="..."]')), mirroringdropdown-primitive.tsx/combobox-primitive.tsx. That effect (and thepauseOnHoverone) mounts exactly once and readsscrollNext/scrollPrev/isPlaying/etc. through refs rather than closing over them directly — putting reactive state in its dependency array reattaches the listener on every change, andpauseOnHover's ownsetIsPlaying(fired bypointerenter, which lands just before the paired click in a real click gesture) would otherwise tear the click listener down in the gap between the mouse arriving and the button being pressed. - Simplifications vs. upstream Ark UI: in-view tracking uses the same
index-range math as the SSR render (not a real
IntersectionObserver), andItemGroup'stabindexis always0(not toggled based on whether a slide contains a focusable element). Both are pragmatic trade-offs that cover the common fixed-slidesPerPagecase; RTL (dir) is not supported, matching the rest of this component library.