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

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
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.
| Component | Description | Import from |
|---|---|---|
| Carousel.Root | The 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.Content | Two 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.Item | One 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.NextTrigger | An 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.IndicatorGroup | A 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.Indicator | One 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, CarouselContext | The 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, CarouselLg | The 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 |
| CarouselIndicator | The 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 |
|---|---|
| PaginationDot | The 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/icons | ChevronLeft 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.


Size is two compositions, not a prop
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
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.



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

3 marks on the sheet, 7 in the window
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.
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.
A vertical carousel needs a height above the track
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.


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