Carousel
Touch-enabled content slider built on a real scroll-snap container, with keyboard navigation, autoplay, and configurable end behavior.
Overview
Carousel is a content slider built on a real horizontally-scrolling CarouselInner track using CSS scroll-snap, rather than transform-based slide positioning. The browser handles touch, trackpad, and mouse-drag scrolling natively; Carousel layers on keyboard navigation, autoplay, and end-of-track behavior on top, tracking which slide is active with an IntersectionObserver rather than a fixed index the caller has to keep in sync by hand.
The family composes from a root and five optional parts: CarouselInner wraps the CarouselItem slides that scroll; CarouselControlPrev/CarouselControlNext step through them; CarouselIndicators renders one button per slide automatically, sized to however many CarouselItems are actually present; CarouselPlayPause toggles autoplay; and CarouselOverlay overlays any of the above on top of the slides instead of stacking them in the flow. Every part reads from Carousel's own state through context, so placement is free-form — controls and indicators can sit above, below, or overlaid on the track in any combination.
Basic usage
A working carousel needs a CarouselInner wrapping at least one CarouselItem; controls and indicators are opt-in, composed alongside it.
import {
Carousel,
CarouselControlNext,
CarouselControlPrev,
CarouselIndicators,
CarouselInner,
CarouselItem,
Placeholder
} from '@chassis-ui/react'
export const Example = () => (
<Carousel>
<div className="d-flex justify-content-between align-items-center">
<div>
<CarouselControlPrev />
<CarouselControlNext />
</div>
<CarouselIndicators />
</div>
<CarouselInner>
<CarouselItem>
<Placeholder width={800} height={400} text="First slide" className="d-block w-100" />
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Second slide"
color="success"
className="d-block w-100"
/>
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Third slide"
color="warning"
className="d-block w-100"
/>
</CarouselItem>
</CarouselInner>
</Carousel>
)Bottom controls
Place CarouselInner before the controls and indicators to render them below the track instead of above it.
import {
Carousel,
CarouselControlNext,
CarouselControlPrev,
CarouselIndicators,
CarouselInner,
CarouselItem,
Placeholder
} from '@chassis-ui/react'
export const Example = () => (
<Carousel>
<CarouselInner>
<CarouselItem>
<Placeholder width={800} height={400} text="First slide" className="d-block w-100" />
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Second slide"
color="success"
className="d-block w-100"
/>
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Third slide"
color="warning"
className="d-block w-100"
/>
</CarouselItem>
</CarouselInner>
<div className="d-flex justify-content-between align-items-center">
<div>
<CarouselControlPrev />
<CarouselControlNext />
</div>
<CarouselIndicators />
</div>
</Carousel>
)Overlay controls
Wrap CarouselControlPrev/CarouselControlNext/CarouselIndicators in CarouselOverlay to overlay them on the slides.
import {
Carousel,
CarouselControlNext,
CarouselControlPrev,
CarouselIndicators,
CarouselInner,
CarouselItem,
CarouselOverlay,
Placeholder
} from '@chassis-ui/react'
export const Example = () => (
<Carousel autoplay>
<CarouselInner>
<CarouselItem>
<Placeholder width={800} height={400} text="First slide" className="d-block w-100" />
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Second slide"
color="success"
className="d-block w-100"
/>
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Third slide"
color="warning"
className="d-block w-100"
/>
</CarouselItem>
</CarouselInner>
<CarouselOverlay>
<CarouselControlPrev />
<CarouselIndicators />
<CarouselControlNext />
</CarouselOverlay>
</Carousel>
)Fade transition
Set transition="fade" to replace the scroll transition with a crossfade. Fade mode stacks every slide instead of scrolling between them, fading the active one in over the --cx-carousel-fade-duration custom property; for optimal crossfading with certain content types, pair it with a background color on CarouselItem.
import {
Carousel,
CarouselControlNext,
CarouselControlPrev,
CarouselInner,
CarouselItem,
CarouselOverlay,
Placeholder
} from '@chassis-ui/react'
export const Example = () => (
<Carousel transition="fade">
<CarouselInner>
<CarouselItem>
<Placeholder width={800} height={400} text="First slide" className="d-block w-100" />
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Second slide"
color="success"
className="d-block w-100"
/>
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Third slide"
color="warning"
className="d-block w-100"
/>
</CarouselItem>
</CarouselInner>
<CarouselOverlay>
<CarouselControlPrev />
<CarouselControlNext />
</CarouselOverlay>
</Carousel>
)Autoplay
Set autoplay to cycle a carousel automatically on mount. Autoplay pauses on mouseenter by default (the pause prop) and stops advancing once the page or the carousel itself isn't visible, per the Page Visibility API. The first control click, arrow-key press, or track drag/tap turns autoplay off for good, so content doesn't keep moving out from under someone who's just started interacting with it — part of complying with WCAG 2.2 Success Criterion 2.2.2 Pause, Stop, Hide.
Autoplaying carousels need an explicit pause/stop control to comply with WCAG 2.2 Success
Criterion 2.2.2 Pause, Stop, Hide. Add a
CarouselPlayPause (shown below) so a viewer can toggle autoplay on and off — it updates its own
icon and accessible name to reflect the current state, from the pauseLabel/playLabel props.
While playing, the active indicator fills like a progress bar over the current slide's interval as a visual countdown; the fill pauses with autoplay and is skipped entirely under reduced motion.
import {
Carousel,
CarouselControlNext,
CarouselControlPrev,
CarouselIndicators,
CarouselInner,
CarouselItem,
CarouselPlayPause,
Placeholder
} from '@chassis-ui/react'
export const Example = () => (
<Carousel autoplay>
<div className="d-flex justify-content-between align-items-center">
<div>
<CarouselPlayPause />
<CarouselControlPrev />
<CarouselControlNext />
</div>
<CarouselIndicators />
</div>
<CarouselInner>
<CarouselItem>
<Placeholder width={800} height={400} text="First slide" className="d-block w-100" />
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Second slide"
color="success"
className="d-block w-100"
/>
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Third slide"
color="warning"
className="d-block w-100"
/>
</CarouselItem>
</CarouselInner>
</Carousel>
)Timing control
Set interval on a CarouselItem to change how long autoplay waits before advancing past that slide, overriding the carousel's own interval. The indicator's progress fill matches each slide's own interval, so the countdown speeds up or slows down to match (2s, then 4s, then 6s below).
import {
Carousel,
CarouselIndicators,
CarouselInner,
CarouselItem,
CarouselPlayPause,
Placeholder
} from '@chassis-ui/react'
export const Example = () => (
<Carousel autoplay>
<CarouselInner>
<CarouselItem interval={2000}>
<Placeholder width={800} height={400} text="First: 2 seconds" className="d-block w-100" />
</CarouselItem>
<CarouselItem interval={4000}>
<Placeholder
width={800}
height={400}
text="Second: 4 seconds"
color="success"
className="d-block w-100"
/>
</CarouselItem>
<CarouselItem interval={6000}>
<Placeholder
width={800}
height={400}
text="Third: 6 seconds"
color="warning"
className="d-block w-100"
/>
</CarouselItem>
</CarouselInner>
<div className="d-flex justify-content-between align-items-center mt-md">
<CarouselPlayPause />
<CarouselIndicators />
</div>
</Carousel>
)End behavior
The ends prop controls what happens at the first and last slide: loop (the default), wrap, or stop.
Loop (default)
With ends="loop" the carousel scrolls seamlessly past the ends: stepping next from the last slide (or previous from the first) continues in the same direction into the destination slide instead of jumping back, for an endless conveyor effect. Seamless looping applies to single-slide carousels driven by the controls, keyboard, or autoplay; multi-item, peek, centered, and variable-width layouts — as well as reduced motion — fall back to the wrap jump.
import {
Carousel,
CarouselControlNext,
CarouselControlPrev,
CarouselInner,
CarouselItem,
Placeholder
} from '@chassis-ui/react'
export const Example = () => (
<Carousel ends="loop">
<CarouselInner>
<CarouselItem>
<Placeholder width={800} height={400} text="First slide" className="d-block w-100" />
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Second slide"
color="success"
className="d-block w-100"
/>
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Third slide"
color="warning"
className="d-block w-100"
/>
</CarouselItem>
</CarouselInner>
<div className="d-flex justify-content-center">
<CarouselControlPrev />
<CarouselControlNext />
</div>
</Carousel>
)Wrap
With ends="wrap" the carousel jumps from the last slide back to the first, and vice versa.
import {
Carousel,
CarouselControlNext,
CarouselControlPrev,
CarouselInner,
CarouselItem,
Placeholder
} from '@chassis-ui/react'
export const Example = () => (
<Carousel ends="wrap">
<CarouselInner>
<CarouselItem>
<Placeholder width={800} height={400} text="First slide" className="d-block w-100" />
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Second slide"
color="success"
className="d-block w-100"
/>
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Third slide"
color="warning"
className="d-block w-100"
/>
</CarouselItem>
</CarouselInner>
<div className="d-flex justify-content-center">
<CarouselControlPrev />
<CarouselControlNext />
</div>
</Carousel>
)Stop
With ends="stop" the carousel hard-stops at the first and last slide: CarouselControlPrev disables on the first slide and CarouselControlNext on the last, via the native disabled attribute. When a focused control becomes disabled, focus shifts to the opposite control so it isn't lost. Autoplay ends when it arrives at the last slide, and CarouselPlayPause reads Play from then on. With nothing left to play, the stopped toggle is disabled on the last slide too, and enabled again once the carousel moves back.
import {
Carousel,
CarouselControlNext,
CarouselControlPrev,
CarouselInner,
CarouselItem,
Placeholder
} from '@chassis-ui/react'
export const Example = () => (
<Carousel ends="stop">
<CarouselInner>
<CarouselItem>
<Placeholder width={800} height={400} text="First slide" className="d-block w-100" />
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Second slide"
color="success"
className="d-block w-100"
/>
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Third slide"
color="warning"
className="d-block w-100"
/>
</CarouselItem>
</CarouselInner>
<div className="d-flex justify-content-center">
<CarouselControlPrev />
<CarouselControlNext />
</div>
</Carousel>
)Custom content
Any markup can go inside a CarouselItem — size and style it with utilities or custom CSS, same as any other element.
Mix and match
Combine custom content with controls, indicators, and the overlay layout.
import {
Button,
Carousel,
CarouselControlNext,
CarouselControlPrev,
CarouselInner,
CarouselItem
} from '@chassis-ui/react'
export const Example = () => (
<Carousel>
<CarouselInner>
<CarouselItem>
<div
className="d-flex flex-column justify-content-center primary-bg-even p-6xl"
style={{ minHeight: 320 }}
>
<h3>Build anything</h3>
<p>Compose slides from any markup — text, buttons, cards, or media.</p>
<div>
<Button color="primary" href="#">
Get started
</Button>
</div>
</div>
</CarouselItem>
<CarouselItem>
<div
className="d-flex flex-column justify-content-center text-center success-bg-even p-6xl"
style={{ minHeight: 320 }}
>
<h3>Style it any way</h3>
<p>Use utilities or custom CSS to size and theme each slide.</p>
<div>
<Button color="success" href="#">
Learn more
</Button>
</div>
</div>
</CarouselItem>
<CarouselItem>
<div
className="d-flex flex-column justify-content-center text-end warning-bg-even p-6xl"
style={{ minHeight: 320 }}
>
<h3>Mix and match</h3>
<p>Combine custom content with controls, indicators, and the overlay layout.</p>
<div>
<Button color="black" href="#">
Browse examples
</Button>
</div>
</div>
</CarouselItem>
</CarouselInner>
<div className="d-flex justify-content-center">
<CarouselControlPrev />
<CarouselControlNext />
</div>
</Carousel>
)Multiple items
Set items to show several whole slides at once. Combine it with itemsGap for spacing. Since seamless looping only applies to single-slide carousels (see Loop (default)), this example sets ends="stop" for a clean boundary instead of a wrap jump.
import {
Carousel,
CarouselControlNext,
CarouselControlPrev,
CarouselInner,
CarouselItem,
Placeholder
} from '@chassis-ui/react'
const slides = ['1', '2', '3', '4', '5', '6', '7']
export const Example = () => (
<Carousel items={3} itemsGap="1rem" ends="stop">
<CarouselInner>
{slides.map((label) => (
<CarouselItem key={label}>
<Placeholder width={260} height={200} text={label} className="d-block w-100 rounded" />
</CarouselItem>
))}
</CarouselInner>
<div className="d-flex justify-content-center">
<CarouselControlPrev />
<CarouselControlNext />
</div>
</Carousel>
)Peeking
Set itemsPeek to reveal a sliver of the previous and next slides, hinting that there's more to scroll.
import {
Carousel,
CarouselControlNext,
CarouselControlPrev,
CarouselInner,
CarouselItem,
Placeholder
} from '@chassis-ui/react'
const slides = ['First slide', 'Second slide', 'Third slide', 'Fourth slide', 'Fifth slide']
export const Example = () => (
<Carousel itemsPeek="3rem" itemsGap="1rem" ends="stop">
<CarouselInner>
{slides.map((label) => (
<CarouselItem key={label}>
<Placeholder width={800} height={300} text={label} className="d-block w-100 rounded-xl" />
</CarouselItem>
))}
</CarouselInner>
<div className="d-flex justify-content-center">
<CarouselControlPrev />
<CarouselControlNext />
</div>
</Carousel>
)Variable width
Set auto and size each CarouselItem individually with utilities or custom CSS; snap points still land on every slide.
import {
Carousel,
CarouselControlNext,
CarouselControlPrev,
CarouselInner,
CarouselItem,
Placeholder
} from '@chassis-ui/react'
const slides = [
{ label: 'A', width: 160 },
{ label: 'B', width: 320 },
{ label: 'C', width: 220 },
{ label: 'D', width: 380 },
{ label: 'E', width: 200 },
{ label: 'F', width: 300 }
]
export const Example = () => (
<Carousel auto itemsGap="1rem" ends="stop">
<CarouselInner>
{slides.map(({ label, width }) => (
<CarouselItem key={label} style={{ width }}>
<Placeholder width={width} height={200} text={label} className="d-block w-100 rounded" />
</CarouselItem>
))}
</CarouselInner>
<div className="d-flex justify-content-center">
<CarouselControlPrev />
<CarouselControlNext />
</div>
</Carousel>
)Set center to snap the active slide to the center of the viewport instead of its start — pairs well with itemsPeek.
Controlled usage
A controlled activeIndex needs an onSlide handler feeding the new index back. Every click, swipe, or autoplay advance calls it with the destination index, and that's the only path a controlled carousel has for updating its own activeIndex. A request the handler doesn't follow moves nothing: a track the user swiped is scrolled back to the active slide.
onSlid can't feed the index back. It fires once a slide has arrived, and a controlled carousel doesn't leave for one until activeIndex names it. It reports every change of activeIndex, with the slides it moved between, including one the parent made by itself.
Omitting onSlide on a controlled activeIndex leaves the carousel visually frozen: every
navigation attempt is a request nothing answers. Carousel warns about this in development,
also when only onSlid is passed.
import { useState } from 'react'
import {
Carousel,
CarouselControlNext,
CarouselControlPrev,
CarouselInner,
CarouselItem,
Placeholder
} from '@chassis-ui/react'
export const Example = () => {
const [activeIndex, setActiveIndex] = useState(0)
return (
<div className="vstack gap-sm">
<Carousel activeIndex={activeIndex} onSlide={({ to }) => setActiveIndex(to)} ends="stop">
<CarouselInner>
<CarouselItem>
<Placeholder width={800} height={400} text="First slide" className="d-block w-100" />
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Second slide"
color="success"
className="d-block w-100"
/>
</CarouselItem>
<CarouselItem>
<Placeholder
width={800}
height={400}
text="Third slide"
color="warning"
className="d-block w-100"
/>
</CarouselItem>
</CarouselInner>
<div className="d-flex justify-content-center">
<CarouselControlPrev />
<CarouselControlNext />
</div>
</Carousel>
<div className="form-help">Active slide: {activeIndex + 1}</div>
</div>
)
}Accessibility
Carousel accessibility centers on keyboard control, focus preservation at the ends, and ARIA state that mirrors the current slide.
With keyboard enabled (the default), Left Arrow and Right Arrow move to the previous and next slide while focus is inside the carousel; either key turns autoplay off for good, the same as a control click. The keys follow the slides: in a right-to-left layout the next slide is to the left, so Left Arrow moves to the next slide and Right Arrow to the previous one. Arrow keys inside a nested <input>, <textarea>, <select>, or [contenteditable] element are left alone so native form navigation keeps working.
CarouselIndicators sets aria-current="true" on the active indicator and gives each one its own aria-label identifying the slide it jumps to (Slide 1, overridable through its label prop). CarouselControlPrev/CarouselControlNext carry a visually-hidden label by default (Previous slide/Next slide, overridable through their own label prop) so the icon-only buttons still have an accessible name.
Scope
- No CarouselCaption. An earlier version shipped a caption sub-component tied to a
.carousel-captionclass that the underlying CSS framework no longer provides. Compose caption content directly inside aCarouselIteminstead — see Custom content.
API
Carousel
| Prop | Type | Default |
|---|---|---|
activeIndex? | number | — |
The active slide's index, for controlled usage. Pair with onSlide to feed the new index
back: it is the only way a controlled carousel's activeIndex gets updated. onSlid can't
do that, as it reports a slide that has arrived, which a controlled carousel doesn't leave
for until activeIndex names it. | ||
auto? | boolean | — |
| Let each CarouselItem size itself instead of dividing the track evenly; snap points still land on every slide. | ||
autoplay? | boolean | false |
| Cycle through slides automatically on mount. | ||
center? | boolean | — |
| Snap the active slide to the center of the viewport instead of its start. | ||
className? | string | — |
| A string of all className you want applied to the base component. | ||
defaultActiveIndex? | number | 0 |
| The active slide's index, for uncontrolled usage. | ||
ends? | CarouselEnds | loop |
Behavior at the first/last slide. loop continues seamlessly past the ends for a single-slide layout, falling back to wrap for multi-item, peek, centered, or variable-width layouts, and under reduced motion. wrap jumps from the last slide back to the first, and vice versa. stop hard-stops and disables the previous/next controls at each end. | ||
interval? | number | 5000 |
Milliseconds to wait before automatically advancing to the next slide. Override per slide with CarouselItem's own interval prop. | ||
items? | number | — |
Number of whole slides visible per view, applied as the --cx-carousel-items custom property. | ||
itemsGap? | string | — |
Space between slides, applied as the --cx-carousel-items-gap custom property. | ||
itemsPeek? | string | — |
How much of the neighboring slides to reveal, applied as the --cx-carousel-items-peek custom property. | ||
keyboard? | boolean | true |
| Move to the previous/next slide with the Left/Right arrow keys while focus is inside the carousel. The keys follow the slides: in a right-to-left layout the next slide is to the left, and Left Arrow moves to it. | ||
onSlid? | ((detail: CarouselSlideDetail) => void) | — |
| Callback fired once a slide transition completes. | ||
onSlide? | ((detail: CarouselSlideDetail) => void) | — |
| Callback fired when a slide transition begins. | ||
pause? | false | "hover" | hover |
Pause autoplay on mouseenter, resuming on mouseleave. Set to false to disable pause-on-hover. | ||
transition? | CarouselTransition | scroll |
| Replace the scroll transition with a crossfade. | ||
CarouselInner
| Prop | Type | Default |
|---|---|---|
asChild? | boolean | — |
Render the single child element in place of this component's own element, with this
component's classes, props and ref merged onto it: <Button asChild><Link href="/login">Log
in</Link></Button>. The alternative to component for React Server Components — an element
can be passed from the server to this package's client components, a component reference
(component={Link}) can't. | ||
className? | string | — |
| A string of all className you want applied to the component. | ||
component? | ElementType | — |
| Component used for the root node. Either a string to use a HTML element or a component. | ||
ref? | any | — |
CarouselItem
| Prop | Type | Default |
|---|---|---|
asChild? | boolean | — |
Render the single child element in place of this component's own element, with this
component's classes, props and ref merged onto it: <Button asChild><Link href="/login">Log
in</Link></Button>. The alternative to component for React Server Components — an element
can be passed from the server to this package's client components, a component reference
(component={Link}) can't. | ||
className? | string | — |
| A string of all className you want applied to the component. | ||
component? | ElementType | — |
| Component used for the root node. Either a string to use a HTML element or a component. | ||
interval? | number | — |
Milliseconds to wait before autoplay advances past this slide, overriding the carousel's own interval. | ||
ref? | any | — |
CarouselControlPrev
| Prop | Type | Default |
|---|---|---|
className? | string | — |
| A string of all className you want applied to the component. | ||
icon? | IconValue | — |
The control's icon: an icon name, or an element of your own icon set. Defaults to
IconProvider's previous icon. | ||
label? | string | Previous slide |
| The accessible label announced by assistive technology. | ||
CarouselControlNext
| Prop | Type | Default |
|---|---|---|
className? | string | — |
| A string of all className you want applied to the component. | ||
icon? | IconValue | — |
The control's icon: an icon name, or an element of your own icon set. Defaults to
IconProvider's next icon. | ||
label? | string | Next slide |
| The accessible label announced by assistive technology. | ||
CarouselIndicators
| Prop | Type | Default |
|---|---|---|
className? | string | — |
| A string of all className you want applied to the component. | ||
label? | ((position: number) => string) | (position) => `Slide ${position}` |
| Accessible label for each indicator button. Receives the slide's 1-based position. | ||
CarouselPlayPause
| Prop | Type | Default |
|---|---|---|
className? | string | — |
| A string of all className you want applied to the component. | ||
pauseIcon? | IconValue | — |
The icon shown while autoplay is running: an icon name, or an element of your own icon set.
Defaults to IconProvider's pause icon. | ||
pauseLabel? | string | Pause |
| The accessible label announced while autoplay is running. | ||
playIcon? | IconValue | — |
The icon shown while autoplay is stopped: an icon name, or an element of your own icon set.
Defaults to IconProvider's play icon. | ||
playLabel? | string | Play |
| The accessible label announced while autoplay is stopped. | ||
CarouselOverlay
| Prop | Type | Default |
|---|---|---|
asChild? | boolean | — |
Render the single child element in place of this component's own element, with this
component's classes, props and ref merged onto it: <Button asChild><Link href="/login">Log
in</Link></Button>. The alternative to component for React Server Components — an element
can be passed from the server to this package's client components, a component reference
(component={Link}) can't. | ||
className? | string | — |
| A string of all className you want applied to the component. | ||
component? | ElementType | — |
| Component used for the root node. Either a string to use a HTML element or a component. | ||
ref? | any | — |