Skip to content
Primitiv home
Framework
Consumption mode

Carousel

stableSource Figma

A responsive, accessible carousel — a scroll-snap viewport of slides with prev/next controls and indicator dots (WAI-ARIA Carousel pattern). Adapts to its container by default.

Playground

Density

Preview

Size
Peek
Gap
Ratio
import { Carousel, CarouselViewport, CarouselSlide, CarouselPreviousTrigger, CarouselNextTrigger, CarouselIndicators } from "@/components/ui/carousel";import { ChevronLeft, ChevronRight } from "@primitiv-ui/icons";
<Carousel ariaLabel="Featured products" size="md" ratio="wide" peek="none" gap="md">  <CarouselViewport>    <CarouselSlide>{/* your slide */}</CarouselSlide>    {/* ...more slides */}  </CarouselViewport>  <CarouselPreviousTrigger aria-label="Previous slide">    <ChevronLeft />  </CarouselPreviousTrigger>  <CarouselNextTrigger aria-label="Next slide">    <ChevronRight />  </CarouselNextTrigger>  <CarouselIndicators label="Choose slide" /></Carousel>

Density is set by a data-density ancestor — the Context system, not a Carousel prop.

Installation

npx primitiv add carousel

Import

import { Carousel } from "@/components/ui/carousel";

Copied into your project as .primitiv-carousel — you own the file afterwards, so upgrades are opt-in.

Headless mode installs the npm package instead: @primitiv-ui/react

Anatomy

<Carousel>  <CarouselViewport>    <CarouselSlide>      <CarouselSlideContent />   {/* optional — the effect layer */}    </CarouselSlide>  </CarouselViewport>
  {/* split (default): controls are direct children */}  <CarouselPreviousTrigger />  <CarouselNextTrigger />  <CarouselIndicators />         {/* or IndicatorGroup + Indicator */}  <CarouselPlayPauseTrigger />   {/* optional — needs autoplay */}</Carousel>

Props

Extends HTMLElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
allowMouseDragboolean—headlessWhether the Viewport supports mouse click-and-drag scrolling — the pointer tracks 1:1 into scrollLeft/scrollTop (no momentum) once past a small movement threshold, release lets the existing scroll-snap-type settle. Defaults to false: an unconditionally-on drag can conflict with a consumer's own drag-sensitive slide content (a nested carousel, a draggable card, a canvas), so it's opt-in. Touch/pen scrolling is unaffected either way — that's native, independent of this prop.
ariaLabelstring—headlessAccessible name for the carousel region, announced as aria-label. Prefer a short, human-readable description (e.g. "Featured products"). Mutually exclusive with ariaLabelledBy.
ariaLabelledBystring—headlessId of an existing element to name the carousel region, set as aria-labelledby. Use when a visible heading already labels the carousel. Mutually exclusive with ariaLabel.
autoplayCarouselAutoplay—headlessAutoplay configuration — see CarouselAutoplay.
defaultPagenumber—headlessUncontrolled active page index. Defaults to 0.
defaultPlayingboolean—headlessUncontrolled initial playing flag. Defaults to false.
idsCarouselIds—headlessPin DOM ids on the rendered sub-components — see CarouselIds. Useful for SSR hydration stability and external aria-controls linkage.
inViewThresholdnumber | number[]—headlessVisibility threshold(s) the isInView fallback's IntersectionObserver uses — both as the observer's own threshold option and (for an array, the highest value) the cutoff a slide's intersectionRatio must clear to count as "in view". Matches Ark UI's inViewThreshold shape. Defaults to 0.6.
loopboolean | "wrap" | "infinite"—headlessWrap-around navigation mode — see CarouselLoopMode. Defaults to false ("none"). Accepts a boolean for ergonomics or a named mode: - false / omitted → "none" (clamp at the ends). - true / "wrap" → "wrap" (semantic wrap: Next/Previous and autoplay wrap past the ends and the triggers never disable, but the wrap last→first smooth-scrolls the track back — a visible rewind). - "infinite" → continuous infinite: the same wrap page model plus a cloned edge buffer and native-scroll recentre so the wrap is a continuous glide with no rewind. All wrapping requires more than one page — a single-page carousel has no wrap target, so the triggers stay disabled regardless. The resolved mode is published as data-loop="none" | "wrap" | "infinite" on the Root.
onAutoplayStatusChange(status: CarouselAutoplayStatus) => void—headlessFires on every autoplay status transition — see CarouselAutoplayStatus. Matches Ark UI's onAutoplayStatusChange. Fires on every tick, not just play/pause toggles — useful for analytics. No-op unless autoplay is enabled.
onDragStatusChange(status: CarouselDragStatus) => void—headlessFires on every mouse-drag status transition — see CarouselDragStatus. Matches Ark UI's onDragStatusChange. No-op unless allowMouseDrag is true (a drag can't start otherwise).
onOverscrollStatusChange(status: CarouselOverscrollStatus) => void—headlessFires whenever the user pushes against a boundary with nowhere further to go, from the keyboard, wheel, or a mouse drag — see CarouselOverscrollStatus.
onPageChange(page: number) => void—headlessCallback invoked when the active page should change (e.g. when the user clicks Carousel.NextTrigger or Carousel.PreviousTrigger). The callback is responsible for re-rendering with the new page.
onPlayingChange(playing: boolean) => void—headlessCallback invoked when the playing flag should toggle. The callback is responsible for re-rendering with the new playing value.
orientation"horizontal" | "vertical"—headlessAxis the carousel scrolls and paginates along — see CarouselOrientation. Defaults to "horizontal". Switches the viewport scroll axis, the arrow-key bindings, and the data-orientation styling hook on the Root.
pagenumber—headlessControlled active page index.
playingboolean—headlessControlled playing flag.
slidesPerMovenumber | "auto"—headlessNumber of slides advanced by Carousel.NextTrigger / Carousel.PreviousTrigger. "auto" (default) advances one full page at a time (= slidesPerPage); a number advances exactly that many slides per click and pages are windowed so the visible window always stays full. A numeric move is coerced to an integer in [1, slidesPerPage] — it can neither drop below one nor skip past a page (which would orphan the slides in the gap) — and the last page is end-aligned to the track end, so every slide stays reachable even when the move doesn't divide the slide count evenly (e.g. 6 slides, slidesPerPage={3}, slidesPerMove={2} yields pages [0,1,2] [2,3,4] [3,4,5]).
slidesPerPagenumber—headlessNumber of slides visible per page. Defaults to 1. With values greater than 1, slides are grouped into pages of that size for navigation purposes: indicators auto-render per page, boundary clamp moves to the last page, and Carousel.NextTrigger / Carousel.PreviousTrigger advance one page at a time. The last page always end-aligns (its window is full, flush with the track end) rather than leaving a partial page — a partial page's leading slide can't align to the viewport start, which desyncs the active page against the scroll. So 7 slides at slidesPerPage={3} are 3 pages — [0,1,2] [3,4,5] [4,5,6] — the last shifted back by one to stay full. Coerced to an integer ≥ 1 — 0, negative, fractional, and non-finite values are clamped (e.g. 2.5 → 2, 0 → 1) so the page maths never divides by zero.
snapAlign"center" | "start" | "end"—headlessScroll-snap alignment the Viewport targets when programmatically scrolling to a page — see CarouselSnapAlign. Defaults to "start". Set to "center" when consumer CSS uses scroll-snap-align: center on slides (e.g. a peek layout where slides are narrower than the Viewport).
snapType"mandatory" | "proximity"—headlessscroll-snap-type strictness — see CarouselSnapType. Defaults to "mandatory".
transition"slide" | "fade" | "none"—headlessVisual transition mode — see CarouselTransition. Defaults to "slide".
translationsCarouselTranslations—headlessOverride the default user-visible strings the component owns — see CarouselTranslations. Useful for i18n.
peek"none" | "sm" | "md" | "lg"nonestyledReveal a sliver of the adjacent slides on either side of the active one. Works in both orientations (inline edges when horizontal, block edges when vertical).
gap"none" | "sm" | "md" | "lg"mdstyledThe spacing between slides. A t-shirt scale re-pointing --primitiv-carousel-gap to a spacing token; md is the default. Works in both orientations (the gap runs on the scroll axis) and composes with every other variant. Note: the padding modifier couples the gap to its inset for a clean framed track, so it overrides this within a padded track.
padding"none" | "sm" | "md" | "lg"nonestyledMake the viewport a padded, framed track: pad the slides inward from the viewport edges and draw the track outline (border + rounded corners). The gap is coupled to the padding so the resting track doesn't itself reveal a neighbour; add peek for a deliberate reveal within the track. The background fill is opt-in via the surface modifier — padding alone renders an outlined track.
surface"none" | "subtle"nonestyledOpt into the viewport track's background fill. Off by default (a framed track is just an outline); pairs with padding to render a filled, framed track.
radius"none" | "md"nonestyledOpt into rounding the container (the viewport track around the slides), independent of the padding frame. Off by default (a square track); md rounds it to the shared, size/density-scaled carousel radius, so the track corners match the slide corners. (Distinct from the per-slide radius modifier, which rounds each slide; this rounds the track that clips them.)
placement"external" | "overlay"externalstyledWhere the controls sit relative to the imagery — the one axis that is off-vs-on the slide. external (the default) keeps the controls off the imagery, in the space around the viewport. overlay insets them on the slide for edge-to-edge imagery. How the controls are arranged (one bar vs prev/next flanking the edges) is the orthogonal cluster axis; side / distribution / align / orientation then compose on top of both.
side"after" | "before"afterstyledWhich cross-axis edge the indicator cluster (or the joined bar) sits on, relative to the scroll direction. after is the trailing edge (below the viewport when horizontal, the inline-end/right side when vertical); before is the leading edge (above / inline-start). Orientation-relative and RTL-safe — it composes with orientation to reach all four physical edges. Read by both placements and both clusters.
distribution"group" | "stretch"groupstyledHow the controls spread along their edge. For joined it drives the whole prev/indicators/next bar — group (the default) bunches them, stretch pushes prev/next to the extremes with the indicators centred (space-between). For split it governs the indicator cluster (prev/next stay flanking the viewport edges) — group bunches the dots, stretch spreads them across the full edge. Applies to both placements.
align"start" | "center" | "end"centerstyledWhere the grouped controls sit along their edge (only read under distribution=group; stretch fills the edge, so alignment is moot). center is the default; start / end pin the cluster to the leading / trailing end — the whole bar for joined, the indicator cluster for split. Applies to both placements. Logical, so it mirrors under RTL and follows the scroll axis when vertical.
cluster"split" | "joined"splitstyledHow the controls are arranged — the orthogonal companion to placement, read by both external and overlay. split (the default) sends prev/next to the viewport's two scroll-axis edges (flanking it — left/right when horizontal, top/bottom when vertical) and leaves the indicators as a separate cluster positioned by side / distribution / align. joined bundles prev + indicators + next into one <CarouselControls> bar that travels together, positioned as a unit by side / distribution / align. Compose the parts inside <CarouselControls> for joined, as direct children of the root for split. Together with placement this is the full 2×2 — external/overlay × split/joined.
indicators"dots" | "thumbnails"dotsstyledWhat the indicators look like. dots (the default) is the compact dot row; thumbnails swaps each indicator for a rounded-rect image thumbnail — the active one ringed in the primary colour, the classic gallery pattern. Supply the thumbnail content as children of each <CarouselIndicator> (an <img> or a background element).
size"xs" | "sm" | "md" | "lg" | "xl"mdstyledScale of the control chrome — the prev/next controls, indicator dots and thumbnails — on a t-shirt ramp, so a carousel matches the density of the UI around it. The viewport and slides stay container-driven (they fill their space); only the controls scale. Composes with the ambient data-density (which shifts every slot): size picks the slot, density shifts it. The prev/next controls track the shared framed-control ramp, so they match a same-size Button.
ratio"square" | "standard" | "wide" | "ultrawide"widestyledThe carousel's aspect ratio, applied uniformly to every slide (and tracked by the thumbnail indicators). Re-points --primitiv-carousel-slide-aspect-ratio at the root; the vertical viewport's shape defaults to the same knob, so ratio shapes the slides in both orientations (square vertical slides, a widescreen vertical scroller, ...). Override the knob directly for a bespoke value. (Per-slide ratios are a later revisit.)
slideWidth"equal" | "content"equalstyledHow each slide's width (block size when vertical) is determined. equal (the default) shares the viewport's content box evenly across slidesPerPage slides, each holding its shape via the ratio aspect-ratio. content lets each slide size to its own content instead — an intrinsically-sized image, an explicit width on the slide, or any content with a natural size — so slides in one track can have genuinely different widths (Ark UI's autoSize). Scoped to slidesPerPage=1: the multi-slide windowing math assumes equal shares, which content-driven widths break.
effect"none" | "parallax"nonestyledAn opt-in scroll-driven visual effect for the slide content, layered on top of scroll-snap paging. none (the default) renders slides plain. parallax gives each slide's <CarouselSlideContent> a native, zero-JavaScript drift as the slide crosses the viewport (Blossom Carousel's Slideshow example) — a CSS view-timeline scoped to the slide drives the content's transform via animation-range: cover, following the scroll axis (inline horizontal, block vertical). The effect requires wrapping the slide's media in <CarouselSlideContent>; browsers without animation-timeline: view() support fall back to reading the headless --slide-progress signal instead (an equivalent, continuously-updated transform with no extra JavaScript of our own), and it disables entirely under prefers-reduced-motion.
glide"fast" | "medium" | "slow"mediumstyledHow fast the infinite loop glides between pages (loop="infinite" only — every other mode uses native scroll). A preset re-points --primitiv-carousel-glide-duration to a motion duration token; the engine reads it, with --primitiv-carousel-glide-easing, off the track and drives the transform transition. medium is the default. For a duration or easing outside the presets, re-point either custom property directly.

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
ariaLabelstring—headlessOverride the auto-generated "N of M" aria-label. Use this when the slide has a more meaningful description than its position (e.g. "Hand-picked for you"). When omitted, slides are labelled with their live index and total in registration order.
snapAlign"center" | "start" | "end"—headlessOverride the root's CarouselRootProps.snapAlign`snapAlign` for this slide only — e.g. a variable-width layout where only some slides should centre or end-align. Only takes effect on a slide that's a valid scroll-snap resting position (a page's leading slide); an interior slide of a multi-slide page never snaps, regardless of this prop. Matches Ark UI's per-Item snapAlign.
radius"md" | "none"mdstyledCorner rounding of the slide.
fit"cover" | "contain"coverstyledHow a media child (an img / picture / video) conforms to the slide box. The slide box is always sized by the layout; this decides how a real image — which has its own intrinsic size and ratio — fills it.
surface"none" | "subtle"nonestyledOptional slide backdrop — the fill behind the media. Off by default (transparent), so a cover image or a slide with its own background is unaffected; opt in to give a contain letterbox (or a transparent image) a surface behind it. Mirrors the root surface modifier and uses the same token.

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildboolean—headlessSee CarouselNextTriggerProps.asChild.

Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildboolean—headlessRender the child element instead of the default <button>. All trigger props (onClick, disabled, ids.nextTrigger, ...) are merged onto the child via Slot. The child must accept a ref.

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
ariaLabelledBystring—headlessId of an existing element to name the indicator group, set as aria-labelledby. Mutually exclusive with label.
labelstring—headlessAccessible name for the indicator group, announced as aria-label (e.g. "Choose slide"). Mutually exclusive with ariaLabelledBy.
readOnlyboolean—headlessForwarded to every generated Carousel.Indicator. See CarouselIndicatorProps.readOnly. Default false.

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
ariaLabelledBystring—headlessId of an existing element to name the indicator group, set as aria-labelledby. Mutually exclusive with label.
labelstring—headlessAccessible name for the indicator group, announced as aria-label (e.g. "Choose slide"). Mutually exclusive with ariaLabelledBy.

Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
index (required)number—headlessZero-based page this indicator targets. Clicking jumps to it.
asChildboolean—headlessRender the child element instead of the default <button>. Trigger props (onClick, aria-label, aria-disabled, data-state) are merged onto the child via Slot. The child must accept a ref.
readOnlyboolean—headlessRenders a presentational-only <span> instead of a <button>: aria-hidden="true", and clicking no longer calls goTo. Use for a progress display when navigation happens some other way (e.g. a carousel driven solely by allowMouseDrag). The consumer's own onClick, if passed, still fires. Default false.

Headless only — the copied file renders this part for you and exports no separate component, so there is nothing to import under Styled.

Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildboolean—headlessRender the child element instead of the default <button>. The child must accept a ref. The render-prop form of children is not supported under asChild; pass a single element instead.
childrenCarouselPlayPauseTriggerChildren—headless

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Styling contract

Control frame

--primitiv-carousel-gap--primitiv-carousel-peek--primitiv-carousel-viewport-padding--primitiv-carousel-viewport-surface--primitiv-carousel-viewport-border-width--primitiv-carousel-viewport-border-color--primitiv-carousel-viewport-radius--primitiv-carousel-slides-per-page--primitiv-carousel-slide-aspect-ratio--primitiv-carousel-vertical-aspect-ratio--primitiv-carousel-radius--primitiv-carousel-slide-radius--primitiv-carousel-slide-object-fit--primitiv-carousel-slide-object-position--primitiv-carousel-slide-surface--primitiv-carousel-block-gap--primitiv-carousel-controls-gap--primitiv-carousel-control-size--primitiv-carousel-control-radius--primitiv-carousel-control-bg--primitiv-carousel-control-bg-hover--primitiv-carousel-control-bg-active--primitiv-carousel-control-fg--primitiv-carousel-control-icon-size--primitiv-carousel-indicator-gap--primitiv-carousel-indicator-hit-area--primitiv-carousel-indicator-size--primitiv-carousel-indicator-size-active--primitiv-carousel-indicator-color--primitiv-carousel-indicator-color-active--primitiv-carousel-progress-text-color--primitiv-carousel-progress-text-font-size--primitiv-carousel-thumbnail-inline-size--primitiv-carousel-thumbnail-aspect-ratio--primitiv-carousel-thumbnail-gap--primitiv-carousel-thumbnail-radius--primitiv-carousel-thumbnail-ring-width--primitiv-carousel-thumbnail-ring-color--primitiv-carousel-thumbnail-inactive-opacity--primitiv-carousel-overlay-control-inset--primitiv-carousel-overlay-control-bg--primitiv-carousel-overlay-control-bg-hover--primitiv-carousel-overlay-control-bg-active--primitiv-carousel-overlay-control-fg--primitiv-carousel-overlay-indicator-inset--primitiv-carousel-overlay-indicator-color--primitiv-carousel-overlay-indicator-color-active--primitiv-carousel-overlay-pill-bg--primitiv-carousel-overlay-pill-padding-inline--primitiv-carousel-overlay-pill-padding-block--primitiv-carousel-fade-duration--primitiv-carousel-fade-easing--primitiv-carousel-glide-duration--primitiv-carousel-glide-easing--primitiv-carousel-parallax-scale--primitiv-carousel-parallax-amount

Panel

--primitiv-carousel-content-space-1--primitiv-carousel-content-space-2--primitiv-carousel-content-space-3--primitiv-carousel-content-space-4

Keyboard

KeyBehaviour
ArrowRight / ArrowLeftAdvance / retreat one page, when orientation is horizontal (the default). Mirrored under dir="rtl".
ArrowDown / ArrowUpThe same, when orientation is vertical.
Home / EndJump to the first / last page.
Enter / SpaceActivate the focused control — a prev/next trigger, an indicator, or the play/pause button.

Data attributes

Carousel

className: .primitiv-carousel

AttributeValueWhen
data-orientationhorizontal | verticalhorizontal / vertical
data-transitionslide | fade | noneslide / fade / none
data-loopnone | wrap | infiniteloop disabled (default) / loop / loop="wrap" (semantic wrap) / loop="infinite" (continuous infinite)

CarouselViewport

className: .primitiv-carousel__viewport

AttributeValueWhen
data-carousel-viewport""always — a structural hook the component's own CSS uses to find this part, and yours may too
data-carousel-track""always — a structural hook the component's own CSS uses to find this part, and yours may too
data-snap-typemandatory | proximitythe `snap` prop, mirrored so the scroll-snap CSS can read it
data-slides-per-page<number>how many slides share the viewport — the slide width is computed from it
data-mouse-drag""`mouseDrag` is enabled, so pointer drag is bound as well as touch
data-carousel-clones<number>how many cloned slides pad each end under infinite loop
data-dragging""a drag is in progress — the hook for suppressing snap and transitions mid-gesture
data-overscrollstart | enda drag has pulled past the first or last slide

CarouselSlide

className: .primitiv-carousel__slide

AttributeValueWhen
data-carousel-slide""always — a structural hook the component's own CSS uses to find this part, and yours may too
data-carousel-clone""this slide is one of the loop clones rather than a real one — exclude it from counts and a11y
data-index<number>this slide's zero-based position
data-total<number>how many real slides there are
data-snap-alignstart | center | endthe `align` prop, mirrored so each slide's scroll-snap-align can read it
data-stateactive | inactiveactive / inactive

CarouselIndicator

className: .primitiv-carousel__indicator

AttributeValueWhen
data-carousel-indicator""always — a structural hook the component's own CSS uses to find this part, and yours may too
data-stateactive | inactiveactive / inactive

Accessibility

  • The Root is the WAI-ARIA Carousel region. Name it with ariaLabel or ariaLabelledBy — without a name a screen-reader user has no idea what the region rotates through. Each Carousel.Slide is a labelled group in the set.
  • The prev/next controls and every indicator are real <button>s wired to the paging state, so Enter and Space activate them and the Viewport itself pages with the arrow keys, Home and End. Give each prev/next trigger its own aria-label — the glyph alone says nothing.
  • Autoplay pauses on hover and on keyboard focus (WCAG 2.2.2, Pause/Stop/Hide), and Carousel.PlayPauseTrigger gives an explicit, discoverable control. Prefer starting paused, or honour prefers-reduced-motion before starting motion on load.
  • effect="parallax" disables itself entirely under prefers-reduced-motion: reduce, so the drift never plays for a user who has asked motion to stop — the slides still page, just without the effect.
  • Loop clones under loop="infinite" are marked data-carousel-clone and kept out of the counts and the accessibility tree, so a screen reader never announces a duplicated slide.
  • Indicator thumbnails are decorative — the <img> inside a Carousel.Indicator should have empty alt, since the indicator's own accessible name already says which slide it jumps to.

Examples

Basic

The minimal carousel: a Carousel.Viewport of Carousel.Slides, prev/next triggers, and Carousel.Indicators (one dot per page, auto). ariaLabel on the Root names the region; give each trigger its own aria-label. This is the default placement="external" cluster="split" layout — prev/next flank the viewport, the dots sit below.

Density
import { Carousel, CarouselViewport, CarouselSlide, CarouselPreviousTrigger, CarouselNextTrigger, CarouselIndicators } from "@/components/ui/carousel";import { ChevronLeft, ChevronRight } from "@primitiv-ui/icons";
<Carousel ariaLabel="Featured products">  <CarouselViewport>    {slides.map((slide) => (      <CarouselSlide key={slide.id}>{/* ... */}</CarouselSlide>    ))}  </CarouselViewport>  <CarouselPreviousTrigger aria-label="Previous slide">    <ChevronLeft />  </CarouselPreviousTrigger>  <CarouselNextTrigger aria-label="Next slide">    <ChevronRight />  </CarouselNextTrigger>  <CarouselIndicators label="Choose slide" /></Carousel>

Control layout

Two orthogonal axes place the controls. placement is off-vs-on the imagery — external (the default) keeps them in the space around the viewport, overlay insets them on the slide for edge-to-edge photography. cluster is the arrangement — split (the default) flanks the viewport with prev/next and leaves the indicators apart, joined bundles prev + indicators + next into one Carousel.Controls bar. side, distribution and align then position the cluster along its edge. Below: an overlay carousel over one with a joined bar.

Density
import { Carousel, CarouselViewport, CarouselSlide, CarouselControls, CarouselPreviousTrigger, CarouselNextTrigger, CarouselIndicators } from "@/components/ui/carousel";import { ChevronLeft, ChevronRight } from "@primitiv-ui/icons";
{/* Controls on the imagery */}<Carousel ariaLabel="Gallery" placement="overlay">  <CarouselViewport>{/* slides */}</CarouselViewport>  <CarouselPreviousTrigger aria-label="Previous"><ChevronLeft /></CarouselPreviousTrigger>  <CarouselNextTrigger aria-label="Next"><ChevronRight /></CarouselNextTrigger>  <CarouselIndicators label="Choose slide" /></Carousel>
{/* prev + indicators + next in one joined bar */}<Carousel ariaLabel="Gallery" cluster="joined">  <CarouselViewport>{/* slides */}</CarouselViewport>  <CarouselControls>    <CarouselPreviousTrigger aria-label="Previous"><ChevronLeft /></CarouselPreviousTrigger>    <CarouselIndicators label="Choose slide" />    <CarouselNextTrigger aria-label="Next"><ChevronRight /></CarouselNextTrigger>  </CarouselControls></Carousel>

Thumbnails

indicators="thumbnails" swaps the dots for a filmstrip — each indicator shows its slide, the active one ringed in the primary colour. Drop the auto Carousel.Indicators for Carousel.IndicatorGroup + one Carousel.Indicator per slide, and put the thumbnail (an <img> or a background element) inside each. index links the indicator to its page.

Density
import { Carousel, CarouselViewport, CarouselSlide, CarouselPreviousTrigger, CarouselNextTrigger, CarouselIndicatorGroup, CarouselIndicator } from "@/components/ui/carousel";import { ChevronLeft, ChevronRight } from "@primitiv-ui/icons";
<Carousel ariaLabel="Gallery" indicators="thumbnails">  <CarouselViewport>{/* slides */}</CarouselViewport>  <CarouselPreviousTrigger aria-label="Previous"><ChevronLeft /></CarouselPreviousTrigger>  <CarouselNextTrigger aria-label="Next"><ChevronRight /></CarouselNextTrigger>  <CarouselIndicatorGroup label="Choose slide">    {slides.map((slide, i) => (      <CarouselIndicator key={slide.id} index={i}>        <img src={slide.thumb} alt="" />      </CarouselIndicator>    ))}  </CarouselIndicatorGroup></Carousel>

Multiple slides per page

slidesPerPage shows several slides at once; each takes an equal share of the viewport (minus the gap). Navigation is then per page — the triggers advance a full page and the auto Carousel.Indicators render one dot per page. slidesPerMove (default one page) advances a set number of slides per click instead, windowing so the visible group always stays full. The last page always end-aligns rather than leaving a partial group.

Density
import { Carousel, CarouselViewport, CarouselSlide, CarouselControls, CarouselPreviousTrigger, CarouselNextTrigger, CarouselIndicators } from "@/components/ui/carousel";import { ChevronLeft, ChevronRight } from "@primitiv-ui/icons";
<Carousel ariaLabel="Products" slidesPerPage={3} gap="md" cluster="joined">  <CarouselViewport>{/* six slides */}</CarouselViewport>  <CarouselControls>    <CarouselPreviousTrigger aria-label="Previous page"><ChevronLeft /></CarouselPreviousTrigger>    <CarouselIndicators label="Choose page" />    <CarouselNextTrigger aria-label="Next page"><ChevronRight /></CarouselNextTrigger>  </CarouselControls></Carousel>

Loop & autoplay

loop wraps navigation past the ends ("wrap" rewinds the track; "infinite" glides on continuously). autoplay={{ delay }} advances on a timer — pair it with loop for an endless hero. Autoplay pauses on hover and focus (WCAG 2.2.2). Give the reader an explicit control too: the headless primitive ships Carousel.PlayPauseTrigger, while a styled consumer wires their own control to the playing / onPlayingChange state (there is no styled play/pause part). This demo starts paused — press play to start the motion.

Density
import { useState } from "react";import { Carousel, CarouselViewport, CarouselSlide, CarouselControls, CarouselPreviousTrigger, CarouselNextTrigger, CarouselIndicators } from "@/components/ui/carousel";import { ChevronLeft, ChevronRight, Play, Pause } from "@primitiv-ui/icons";import { Button } from "@/components/ui/button";
const [playing, setPlaying] = useState(false);
<Carousel  ariaLabel="Featured"  loop  autoplay={{ delay: 3000 }}  playing={playing}  onPlayingChange={setPlaying}>  <CarouselViewport>{/* slides */}</CarouselViewport>  <CarouselPreviousTrigger aria-label="Previous"><ChevronLeft /></CarouselPreviousTrigger>  <CarouselNextTrigger aria-label="Next"><ChevronRight /></CarouselNextTrigger>  <CarouselIndicators label="Choose slide" /></Carousel><Button variant="secondary" onClick={() => setPlaying((p) => !p)}>  {playing ? <Pause /> : <Play />}  {playing ? "Pause" : "Play"}</Button>

Slideshow (parallax)

effect="parallax" drifts each slide's content against the scroll as the slide crosses the viewport — a native, zero-JavaScript effect driven by a CSS view-timeline. It requires wrapping the slide's media in Carousel.SlideContent, the layer the animation targets. Browsers without animation-timeline: view() fall back to an equivalent transform off the headless progress signal, and it disables entirely under prefers-reduced-motion. Page across the slides to see the drift.

Density
import { Carousel, CarouselViewport, CarouselSlide, CarouselSlideContent, CarouselPreviousTrigger, CarouselNextTrigger, CarouselIndicators } from "@/components/ui/carousel";import { ChevronLeft, ChevronRight } from "@primitiv-ui/icons";
<Carousel ariaLabel="Slideshow" effect="parallax">  <CarouselViewport>    {slides.map((slide) => (      <CarouselSlide key={slide.id}>        <CarouselSlideContent>          <img src={slide.src} alt={slide.alt} />        </CarouselSlideContent>      </CarouselSlide>    ))}  </CarouselViewport>  <CarouselPreviousTrigger aria-label="Previous"><ChevronLeft /></CarouselPreviousTrigger>  <CarouselNextTrigger aria-label="Next"><ChevronRight /></CarouselNextTrigger>  <CarouselIndicators label="Choose slide" /></Carousel>

Vertical orientation

orientation="vertical" scrolls and pages on the block axis (up/down); the viewport sits beside a stacked control column and the arrow keys become ArrowUp / ArrowDown. Everything else composes unchanged — swap the trigger glyphs for ChevronUp / ChevronDown so they point the way they move.

Density
import { Carousel, CarouselViewport, CarouselSlide, CarouselControls, CarouselPreviousTrigger, CarouselNextTrigger, CarouselIndicators } from "@/components/ui/carousel";import { ChevronUp, ChevronDown } from "@primitiv-ui/icons";
<Carousel ariaLabel="Featured" orientation="vertical" cluster="joined">  <CarouselViewport>{/* slides */}</CarouselViewport>  <CarouselControls>    <CarouselPreviousTrigger aria-label="Previous"><ChevronUp /></CarouselPreviousTrigger>    <CarouselIndicators label="Choose slide" />    <CarouselNextTrigger aria-label="Next"><ChevronDown /></CarouselNextTrigger>  </CarouselControls></Carousel>

Mouse drag

allowMouseDrag lets a mouse click-and-drag scroll the viewport, tracking the pointer 1:1 until release lets scroll-snap settle. It is off by default — an always-on drag can fight drag-sensitive slide content (a nested carousel, a draggable card) — so it is opt-in. Touch and pen scrolling are native and unaffected either way. Click and drag the slides below.

Density
import { Carousel, CarouselViewport, CarouselSlide, CarouselPreviousTrigger, CarouselNextTrigger, CarouselIndicators } from "@/components/ui/carousel";import { ChevronLeft, ChevronRight } from "@primitiv-ui/icons";
<Carousel ariaLabel="Featured" allowMouseDrag>  <CarouselViewport>{/* slides */}</CarouselViewport>  <CarouselPreviousTrigger aria-label="Previous"><ChevronLeft /></CarouselPreviousTrigger>  <CarouselNextTrigger aria-label="Next"><ChevronRight /></CarouselNextTrigger>  <CarouselIndicators label="Choose slide" /></Carousel>

Slide fit

When a slide holds a real image, fit on Carousel.Slide decides how it fills the slide box. cover (the default) fills and crops, preserving the image's ratio — best for photography. contain fits the whole image without cropping and letterboxes the rest; pair it with surface="subtle" to give the letterbox a backdrop. Page between the two slides below — same image, cover then contain.

Density
import { Carousel, CarouselViewport, CarouselSlide, CarouselPreviousTrigger, CarouselNextTrigger, CarouselIndicators } from "@/components/ui/carousel";import { ChevronLeft, ChevronRight } from "@primitiv-ui/icons";
<Carousel ariaLabel="Fit demo">  <CarouselViewport>    <CarouselSlide fit="cover">      <img src="/photo.jpg" alt="A landscape" />    </CarouselSlide>    <CarouselSlide fit="contain" surface="subtle">      <img src="/photo.jpg" alt="A landscape" />    </CarouselSlide>  </CarouselViewport>  <CarouselPreviousTrigger aria-label="Previous"><ChevronLeft /></CarouselPreviousTrigger>  <CarouselNextTrigger aria-label="Next"><ChevronRight /></CarouselNextTrigger>  <CarouselIndicators label="Choose slide" /></Carousel>