Carousel

One slide of a set at a time: a headless Carousel namespace over embla with useCarousel, drawing nothing. All drawn values live in the CarouselMd/Lg demos.

Demo

Two subjects: the demo composition as shipped, and the headless base with text slides. Change props and copy the result.

Rubber plant leaves against a pale wall
A palm frond against a blue sky
A single pink gerbera on a blue-gray background
Component
size
Style
import { ChevronLeft, ChevronRight } from "@untitledui/icons"; import { Carousel, useCarousel } from "@/components/application/carousel/carousel-base"; import { PaginationDot } from "@/components/application/pagination/pagination-dot"; // The demo's CarouselIndicator with the prop it drops: a name for the nav. const Dots = ({ label }: { label: string }) => { const { selectedIndex, scrollSnaps, api } = useCarousel(); return ( <PaginationDot ariaLabel={label} framed className="forced-colors:outline-1 forced-colors:-outline-offset-1" page={selectedIndex + 1} total={scrollSnaps.length} onPageChange={(page) => api?.scrollTo(page - 1)} /> ); }; <Carousel.Root aria-label="Plant gallery" className="relative aspect-[1.6] max-w-160"> <Carousel.PrevTrigger className="bg-alpha-white/90 text-fg-secondary outline-focus-ring absolute top-1/2 z-10 flex -translate-y-1/2 cursor-pointer items-center justify-center rounded-full p-2 backdrop-blur-md focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 forced-colors:outline-1 forced-colors:-outline-offset-1 forced-colors:disabled:text-[GrayText] size-9 start-4"> <ChevronLeft className="size-5" /> </Carousel.PrevTrigger> <Carousel.NextTrigger className="bg-alpha-white/90 text-fg-secondary outline-focus-ring absolute top-1/2 z-10 flex -translate-y-1/2 cursor-pointer items-center justify-center rounded-full p-2 backdrop-blur-md focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 forced-colors:outline-1 forced-colors:-outline-offset-1 forced-colors:disabled:text-[GrayText] size-9 end-4"> <ChevronRight className="size-5" /> </Carousel.NextTrigger> <div className="absolute bottom-4 left-1/2 z-10 -translate-x-1/2"> <Dots label="Plant gallery slides" /> </div> <Carousel.Content className="gap-2"> <Carousel.Item className="overflow-hidden rounded-xl"> <img alt="Rubber plant leaves against a pale wall" src="https://www.untitledui.com/application/plants.webp" className="size-full object-cover" /> </Carousel.Item> <Carousel.Item className="overflow-hidden rounded-xl"> <img alt="A palm frond against a blue sky" src="https://images.unsplash.com/photo-1484506097116-1bcba4fa7568?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" className="size-full object-cover" /> </Carousel.Item> <Carousel.Item className="overflow-hidden rounded-xl"> <img alt="A single pink gerbera on a blue-gray background" src="https://images.unsplash.com/photo-1471899236350-e3016bf1e69e?q=80&w=2971&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" className="size-full object-cover" /> </Carousel.Item> </Carousel.Content> </Carousel.Root>

Seven parts, one snippet

Carousel.Root provides, Carousel.Content scrolls, Carousel.Item is one slide, and the arrows and the dots are separate parts you add. The copied snippet carries all of them, because no single element reproduces what the preview shows. The size control is not a prop of anything in the base: it picks between the two demo compositions, which is where the 36px and 44px arrows and the 8px and 10px dots live. Style is the sheet’s own axis on the tray, dot against line, drawn by the pagination family’s two components. The tray in the copied code is PaginationDot with an ariaLabel, the demo’s CarouselIndicator plus the one prop it drops, so a page with several carousels does not get several navs with one name — or PaginationLine with the width its bars need, when the Style control says line.

Contextual demo

A carousel is only ever part of something. The preview puts four of them in a real screen.

Thirteen consumers already ship it

Three modals, centered-photo-carousel-modal, centered-video-carousel-modal, and form-01-modal, render metric charts, videos, and form fields as slides, with the demo’s CarouselIndicator at lg and their own Back and Next from the context, each pair with its own end labels. Both sign-up assets, login-split-carousel and signup-split-carousel, put unframed dots between two bare arrows under the slides, the second with isBrand. And eight marketing sections, blog, content, team, and testimonial layouts, scroll cards with align: "start", overflowHidden={false}, and their own round buttons through asChild. Read from the source; the fixtures on this page are shaped like the photo modal’s framing copy and the sign-up carousels’ one-title-one-sentence slides.

The family

Seven parts on one Carousel namespace plus the hook and the context; three demo exports hold every drawn value.

The components in this family, with their descriptions and import paths
ComponentDescriptionImport from
Carousel.RootThe region. Creates the embla instance from opts, plugins, and orientation, owns the selected index and the can-scroll flags, provides them through context, and listens for the arrow keys. Named “Carousel” by default since 2026-08-23; pass aria-label to say what it holds.@/​components/​application/​carousel/​carousel-base
Carousel.ContentTwo divs: the viewport embla measures, with overflow-hidden unless overflowHidden is false, and inside it the flex track. Your className lands on the track, not the viewport.@/​components/​application/​carousel/​carousel-base
Carousel.ItemOne slide: a group with a slide role description, basis-full so one fills the view, named “N of M” from embla’s slide list since 2026-08-23. Put the image, the card, or the form step inside it.@/​components/​application/​carousel/​carousel-base
Carousel.PrevTrigger, Carousel.NextTriggerAn unstyled button that scrolls one snap and disables itself at the ends, named “Previous slide” and “Next slide”. Takes asChild to clone your element, or a render prop that receives isDisabled, onClick, and the name.@/​components/​application/​carousel/​carousel-base
Carousel.IndicatorGroupA nav named “Slides” by default, which maps one render-prop call per scroll snap, keyed by index since 2026-08-23. Or pass children and lay the marks out yourself.@/​components/​application/​carousel/​carousel-base
Carousel.IndicatorOne unstyled mark: a button named “Go to slide N” with aria-current while selected, scrolling to its index on click. Takes every button prop and a ref since 2026-08-23.@/​components/​application/​carousel/​carousel-base
useCarousel, CarouselContextThe context the parts read: the embla api, scrollPrev and scrollNext, canScrollPrev and canScrollNext, selectedIndex, and scrollSnaps. What the three carousel modals use to drive their own Back and Next buttons.@/​components/​application/​carousel/​carousel-base
CarouselMd, CarouselLgThe kit’s two demo compositions, which are where every drawn value lives: a 640×400 photo track, two round arrows over it, and a framed dot tray at the bottom. They take no props.@/​components/​application/​carousel/​carousel.demo
CarouselIndicatorThe demo’s dot tray: PaginationDot fed the selected index and the snap count from context, with size, framed, isBrand, and className. Five consumers in this repo import it.@/​components/​application/​carousel/​carousel.demo

Built from

Five pieces make up the carousel; two have docs pages: the icons, and the dots on the pagination page.

Component
Where it’s used here
PaginationDotThe demo’s dot tray, through CarouselIndicator: the two sizes, the framed pill, isBrand, the hit area, and the forced-colors border. Documented on the pagination page.
@untitledui/iconsChevronLeft and ChevronRight in the two demo compositions, at size-5 and size-6. The base renders no icon of its own.

The two compositions

CarouselMd and CarouselLg: a 640×400 photo track, two round arrows, and a framed dot tray. Live — arrows, dots, drag.

Rubber plant leaves against a pale wall
A palm frond against a blue sky
A single pink gerbera on a blue-gray background
md: 36px arrows 16px in, 8px dots in a p-2 tray
Rubber plant leaves against a pale wall
A palm frond against a blue sky
A single pink gerbera on a blue-gray background
lg: 44px arrows 20px in, 10px dots in a p-3 tray

Size is two compositions, not a prop

Read from the source: Carousel.Root takes no size, and neither does any part of the base. The sheet’s Size axis is reproduced by two functions in carousel.demo.tsx that differ in four classes, size-9 against size-11 on the arrow, size-5 against size-6 on the chevron, start-4/end-4 against start-5/end-5 on the inset, and size="lg" on the dot tray. This page’s cells reuse those class strings with the page’s fixtures. The lg arrow’s 10px optical inset on the sheet is the 44px box around a 24px glyph with 8px of padding, not a shortfall.

Dark mode flips the fills, and the current dot goes gray

Read from theme.css. The arrows and the framed tray are bg-alpha-white/90: the alpha-white token at 90%, which is white in light mode and rgb(12 14 18) in dark, so both go near-black over the photo there, where the sheet draws light only. The chevron’s text-fg-secondary flips from interface-700 to interface-300. The dots are semantic aliases too: bg-quaternary goes from interface-200 to interface-700, and the current mark’s bg-fg-brand-primary_alt goes from brand-600 to interface-300, so in dark mode the current dot is a light gray rather than brand. Only the brand pair, bg-fg-brand-secondary and bg-fg-white, keeps its values in both modes. Bare interface-* classes would not flip; every class here is an alias, so every one does.

Indicators

Six ways to draw the marks. The demo’s CarouselIndicator is PaginationDot; the base’s Indicator is a bare button.

Rubber plant leaves against a pale wall
A palm frond against a blue sky
A single pink gerbera on a blue-gray background
The demo’s CarouselIndicator framed over the photo, as five consumers import it; its nav reads “Pagination Navigation”
Rubber plant leaves against a pale wall
A palm frond against a blue sky
A single pink gerbera on a blue-gray background
Unframed between two arrows under the photo, the sign-up carousels’ layout; PaginationDot with an ariaLabel of its own
Rubber plant leaves against a pale wall
A palm frond against a blue sky
A single pink gerbera on a blue-gray background
The base’s Carousel.IndicatorGroup and Carousel.Indicator through both render props: a pill that widens when selected

Invite your team

Add teammates from Settings and they see every project you share.

Connect your tools

Slack, GitHub, and Figma sync from Integrations in under a minute.

Create your first project

Start from a template or a blank board; the layout can change later.

isBrand at lg on a brand ground, unframed, with bare white arrows: signup-split-carousel’s layout

Tip 1 of 12

Add teammates from Settings and they see every project you share.

Tip 2 of 12

Slack, GitHub, and Figma sync from Integrations in under a minute.

Tip 3 of 12

Start from a template or a blank board; the layout can change later.

Tip 4 of 12

Add teammates from Settings and they see every project you share.

Tip 5 of 12

Slack, GitHub, and Figma sync from Integrations in under a minute.

Tip 6 of 12

Start from a template or a blank board; the layout can change later.

Tip 7 of 12

Add teammates from Settings and they see every project you share.

Tip 8 of 12

Slack, GitHub, and Figma sync from Integrations in under a minute.

Tip 9 of 12

Start from a template or a blank board; the layout can change later.

Tip 10 of 12

Add teammates from Settings and they see every project you share.

Tip 11 of 12

Slack, GitHub, and Figma sync from Integrations in under a minute.

Tip 12 of 12

Start from a template or a blank board; the layout can change later.

12 slides behind PaginationDot’s 7-mark window: the marks slide to keep the current one in view and stop counting
Rubber plant leaves against a pale wall
A palm frond against a blue sky
A single pink gerbera on a blue-gray background
The sheet’s Line style, framed over the photo: PaginationLine fed the same two numbers the dot tray gets

3 marks on the sheet, 7 in the window

Every dot group on the sheet draws 3 marks; the kit’s PaginationDot shows up to siblingCount * 2 + 5, 7 by default, and slides the window past that, as the 12-slide cell above shows; the pagination page documents it as the indicator’s own window, a 2026-08-21 edit there, and this page takes it as a behavior rather than a specimen count. The demo’s CarouselIndicator cannot change it: it passes 4 props to PaginationDot and drops siblingCount and ariaLabel with the rest, so every demo tray is a nav named “Pagination Navigation” with marks named “Page N”. That is why every other tray on this page renders PaginationDot, or the line cell’s PaginationLine, directly with an ariaLabel, the route when a page has two carousels, and why the first cell is the only one named the demo’s way. The base’s own marks read “Go to slide N” inside a nav named “Slides”, both 2026-08-23 defaults, and both replaceable; they have no window, one mark per snap.

Vertical

orientation=“vertical” sets embla’s y axis, flex-col, and ArrowUp/ArrowDown. A kit-extra axis the sheet does not draw.

Invite your team

Add teammates from Settings and they see every project you share.

Connect your tools

Slack, GitHub, and Figma sync from Integrations in under a minute.

Create your first project

Start from a template or a blank board; the layout can change later.

Vertical, three text slides in a 256px viewport: tab to an arrow, then ArrowUp and ArrowDown

Invite your team

Add teammates from Settings and they see every project you share.

Connect your tools

Slack, GitHub, and Figma sync from Integrations in under a minute.

Create your first project

Start from a template or a blank board; the layout can change later.

The same three slides horizontal, for comparison

A vertical carousel needs a height above the track

Read from the source: each slide is basis-full, the track is flex-col max-h-full, and the viewport is h-full. None of them has a height until a wrapper does, so the cell above gives one h-64 and passes className="h-full" to Carousel.Content, which lands on the track. Without both, the slides stack at their content height and nothing scrolls. Nothing in the repo renders a vertical carousel; the axis exists in the base and in no sheet symbol.

States

The sheet has no State axis. The kit has two: disabled at the ends and focus, on arrows and dots. No hover anywhere.

Rubber plant leaves against a pale wall
A palm frond against a blue sky
A single pink gerbera on a blue-gray background
Previous is disabled at the first slide: half opacity, not-allowed cursor, disabled attribute; the last disables Next
Rubber plant leaves against a pale wall
A palm frond against a blue sky
A single pink gerbera on a blue-gray background
opts.loop keeps both arrows live: the last slide’s Next lands on the first

Disabled is half opacity, and the attribute is real

Carousel.PrevTrigger reads canScrollPrev from the context and renders disabled when it is false, which the demo paints with disabled:opacity-50 and disabled:cursor-not-allowed. The attribute reaches assistive technology, so the state is announced; the opacity is the only visual. With asChild the clone receives disabled too; with the render prop you receive isDisabled and set it yourself. The sheet draws no disabled arrow.

Focus, forced colors, and reduced motion

Read from the source. The demo arrows and every pagination dot carry outline-focus-ring at 2px with a 2px offset on focus-visible, and no outline-none anywhere in the family. Under forced colors the translucent fill and blur that separate an arrow or the framed tray from the photo are dropped, so since 2026-08-23 both carry forced-colors:outline-1 forced-colors:-outline-offset-1 and a disabled arrow reads forced-colors:disabled:text-[GrayText]; the dots have had forced-colors:border with a Highlight fill on the current one since 2026-08-22. Under prefers-reduced-motion: reduce the root passes embla duration: 0, also since 2026-08-23, so the track jumps rather than slides. None of this is measured yet: the Code tab’s Accessibility section says what the measurement will cover.