Skip to main contentSkip to docs navigation

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.

JSX
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.

JSX
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.

JSX
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.

JSX
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.

JSX
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).

JSX
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.

JSX
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.

JSX
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.

JSX
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.

JSX
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.

JSX
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.

JSX
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.

JSX
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.

Active slide: 1
JSX
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-caption class that the underlying CSS framework no longer provides. Compose caption content directly inside a CarouselItem instead — see Custom content.

API

PropTypeDefault
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?booleanfalse
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?number0
The active slide's index, for uncontrolled usage.
ends?CarouselEndsloop
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?number5000
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?booleantrue
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?CarouselTransitionscroll
Replace the scroll transition with a crossfade.

CarouselInner

PropTypeDefault
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

PropTypeDefault
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

PropTypeDefault
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?stringPrevious slide
The accessible label announced by assistive technology.

CarouselControlNext

PropTypeDefault
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?stringNext slide
The accessible label announced by assistive technology.

CarouselIndicators

PropTypeDefault
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

PropTypeDefault
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?stringPause
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?stringPlay
The accessible label announced while autoplay is stopped.

CarouselOverlay

PropTypeDefault
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—