RoadieRoadie
  • Home
  • Brand
  • Foundations
  • Content
  • Tokens
  • Components
  • Charts
  • Widgets
  • Appearance
HomeBrandFoundations
Appearance

Components

  • Navigation

    • Breadcrumb
    • LinkBeta
    • NavigatorAlpha
    • Steps
    • Tabs
  • Layout

    • Accordion
    • CollapsibleBeta
    • PaneAlpha
    • ScrollArea
    • Separator
  • Actions

    • Button
    • IconButton
    • ToggleBeta
    • Toggle groupBeta
    • ToolbarBeta
  • Fields

    • EditorAlpha
    • Field
    • Fieldset
    • Input
    • Label
    • Number fieldBeta
    • OTP fieldBeta
    • Query fieldAlpha
    • Textarea
  • Choices

    • Autocomplete
    • CheckboxBeta
    • Combobox
    • Radio group
    • Select
    • SliderBeta
    • SwitchBeta
  • Date and time

    • CalendarBeta
    • CalendarTile
    • Countdown
    • Dashboard periodBeta
    • Date fieldBeta
    • Date pickerBeta
    • Date range pickerBeta
    • DateTime
    • Duration
    • Time fieldBeta
  • Collections

    • Card
    • Carousel
    • DataTableBeta
    • ListAlpha
    • Marquee
    • RecordGridAlpha
    • RecordTableAlpha
    • SortableAlpha
    • TableBeta
  • Overlays

    • Dialog
    • Drawer
    • MenuBeta
    • Popover
    • ToastBeta
    • Tooltip
  • Status

    • Badge
    • CalloutBeta
    • DeltaBeta
    • Empty state
    • MeterBeta
    • ProgressBeta
    • SkeletonBeta
    • SparklineBeta
    • StatTileBeta
  • Media & brand

    • AvatarBeta
    • Icon Tile
    • Image
    • Logo
    • PatternBeta
    • QR codeBeta
    • SpotIllustration
  • Text

    • Code
    • Highlight
    • KbdBeta
    • Mark
    • Prose

Carousel

Status
Stable
Added
Components 2.1.014 Apr 2026
Updated
Components 2.16.020 Oct 2026
Docs updated
20 Oct 2026

A compound horizontal or vertical carousel built on Embla, with built-in autoplay, keyboard navigation, and prefers-reduced-motion handling.

Import

import { Carousel } from '@oztix/roadie-components/carousel'

Examples

Default

The simplest usage: a Title, a centered row of Dots, and Previous/Next nav buttons all inside a single Carousel.Header. On mobile the controls hide automatically (touch users swipe instead) and the dots re-pin to the right; from md upwards three slides sit in view and the dots sit centered between the title and the buttons.

<Carousel aria-label='Default carousel'>
<Carousel.Header>
<Carousel.Title>Upcoming shows</Carousel.Title>
<Carousel.Dots />
<Carousel.Controls>
<Carousel.Previous />

Overflow

Carousel.Content takes an overflow prop that controls how slides behave at the viewport edges.

  • subtle (default): slides bleed past the edges by the gutter width and dissolve into the page background via ::before / ::after linear-gradient overlays. Gives a clear scroll hint without half-clipped cards in view.
  • hidden: slides are hard-clipped exactly at the viewport edge.
  • visible: slides extend indefinitely past the viewport without clipping, useful on wide screens where you deliberately want peeking content to remain fully rendered in the surrounding margin.
<Carousel aria-label='Subtle overflow'>
<Carousel.Header>
<Carousel.Title>Subtle (default)</Carousel.Title>
</Carousel.Header>
<Carousel.Content overflow='subtle'>
<Carousel.Item className='basis-[42%] md:basis-1/3'>

overflow='visible' is bounded by whatever ancestor has overflow: clip / hidden / auto on it. Inside the docs preview the preview itself clips, so it reads similarly to hidden. In a real layout with a wider parent it lets slides render into adjacent margin without the gradient fade.

With linked cards

Each Carousel.Item can wrap a clickable element. The Card component renders as an anchor when given an href, and Carousel's keyboard handler is smart enough not to advance slides when arrow keys hit a focusable element inside a slide, so Tab lands on each card's link in turn.

<Carousel aria-label='Linked event cards'>
<Carousel.Header>
<Carousel.TitleLink href='/events'>Browse all events</Carousel.TitleLink>
<Carousel.Dots />
<Carousel.Controls>
<Carousel.Previous />

With deferred images

Embla mounts every slide in the DOM, so native loading='lazy' still fetches off-screen slides. Give each Card.Image a width and defer so the full image only loads once the slide nears the viewport, the canonical pattern for image-heavy carousels. placeholder='blur' keeps the tile from popping in.

<Carousel aria-label='Events with artwork'>
<Carousel.Header>
<Carousel.TitleLink href='/events'>What's on</Carousel.TitleLink>
<Carousel.Controls>
<Carousel.Previous />
<Carousel.Next />

Responsive group scrolling

Roadie defaults slidesToScroll to 'auto' in opts, so Previous / Next move by a full viewport at a time instead of a single slide. Embla derives the group size from whatever currently fits in the viewport, so the same carousel scrolls two-and-a-bit cards on mobile, three on medium screens, and four on large screens, with no breakpoint-specific configuration needed. Because Carousel.Dots renders one dot per snap, the dot count also collapses from one-per-slide down to one-per-page as the breakpoint changes.

<Carousel aria-label='Events by page'>
<Carousel.Header>
<Carousel.Title>This weekend</Carousel.Title>
<Carousel.Dots />
<Carousel.Controls>
<Carousel.Previous />

If you'd rather step through one slide at a time (handy when each slide has rich content a user needs to dwell on), pass opts={{ slidesToScroll: 1 }} to override the 'auto' default. Carousel.Dots then renders one per slide rather than one per page, and Previous / Next advance one snap per click.

<Carousel
aria-label='Step through each story'
opts={{ slidesToScroll: 1 }}
>
<Carousel.Header>
<Carousel.Title>Tour stories</Carousel.Title>

Fixed group sizes also work: opts={{ slidesToScroll: 3 }} always advances three at a time regardless of viewport. See the full list of Embla options in the Embla Carousel options reference.

Variable-width cards

Set basis-auto on each Carousel.Item and every card sizes to its own content. Handy for genre pills, tag clouds, and anything chip-shaped where the slide widths are irregular. The default slidesToScroll: 'auto' still groups slides into viewport-sized pages for the Previous/Next buttons, so Embla handles the "how many fit" math even when the slide widths differ.

<Carousel aria-label='Browse by genre'>
<Carousel.Header>
<Carousel.Title>Browse by genre</Carousel.Title>
<Carousel.Dots />
<Carousel.Controls>
<Carousel.Previous />

Featured (autoplay + loop)

Auto-rotating hero carousel with a persistent pause control. The Carousel.PlayPause button is placed first inside Carousel.Controls so it is the first tab stop, required by WCAG 2.2.2. Slides run edge-to-edge with a sliver of the next slide peeking in.

Carousel.TitleLink takes href, which routes through the Link given to RoadieProvider. The trailing arrow icon and link styling come along automatically.

<Carousel
aria-label='Featured events'
autoPlay={5000}
opts={{ loop: true }}
>
<Carousel.Header>

Fits in viewport (controls auto-hide)

When every slide already fits in the viewport, Carousel.Previous, Carousel.Next, Carousel.Dots, and Carousel.Controls all hide themselves automatically. Resize the preview wider to see the difference.

<Carousel aria-label='Comedy lineup'>
<Carousel.Header>
<Carousel.TitleLink href='/comedy'>Comedy</Carousel.TitleLink>
<Carousel.Dots />
<Carousel.Controls>
<Carousel.Previous />

With dots only

A simpler hero pattern: drop Carousel.Controls and let the dots handle navigation. With only a Title and Dots, the Header collapses to a 2-slot layout (title left, dots right).

<Carousel aria-label='With dots'>
<Carousel.Header>
<Carousel.Title>Slides</Carousel.Title>
<Carousel.Dots />
</Carousel.Header>
<Carousel.Content>

Vertical

Pass direction='vertical' to flip the axis. The Previous/Next icons swap to up/down carets automatically.

<Carousel aria-label='Vertical carousel' direction='vertical' className='h-64'>
<Carousel.Header>
<Carousel.Title>Vertical</Carousel.Title>
<Carousel.Controls>
<Carousel.Previous />
<Carousel.Next />

States

Carousel.Previous and Carousel.Next are disabled at the start and end boundaries unless loop is set. Carousel.PlayPause toggles its icon and aria-pressed on click. The whole nav UI hides when there is nothing to scroll to.

<Carousel aria-label='State demo' autoPlay={5000}>
<Carousel.Header>
<Carousel.Title>Boundary + autoplay states</Carousel.Title>
<Carousel.Dots />
<Carousel.Controls>
<Carousel.PlayPause />

Composition

Carousel is a compound component. Available parts:

PartRole
CarouselRoot. Owns the Embla instance and context.
Carousel.HeaderThree-slot layout shell: title (left), dots (centre), controls (right). On mobile it collapses to a flex justify-between row.
Carousel.Title<h2> by default; pass render={<h3 />} (etc.) to change the heading level. Registers as the carousel's accessible name.
Carousel.TitleLinkAnchor with a trailing arrow icon. Pass render={<Link />} to use a framework router link. Registers as the carousel's accessible name.
Carousel.ControlsInline flex row for grouping Carousel.PlayPause, Carousel.Previous, and Carousel.Next (or any other inline buttons) with consistent spacing. Hidden on mobile by default; hidden entirely when there's nothing to scroll to.
Carousel.ContentEmbla viewport + container. Wraps each direct Carousel.Item child in per-item context.
Carousel.ItemA single slide. Set the width with Tailwind basis-* utilities.
Carousel.Previous / Carousel.NextNav buttons (compose IconButton). Auto-hide when there's nothing to scroll to.
Carousel.PlayPausePlay/pause toggle. Renders only when autoPlay is set.
Carousel.DotsOne button per scroll snap position (which can be fewer than slideCount when multi-visible layouts group slides into viewport-sized pages, the Roadie default). Auto-hides when there's nothing to scroll to.

Guidelines

  • Slide sizing is the consumer's job. Set width on each Carousel.Item via Tailwind basis-* utilities (e.g. basis-1/2 md:basis-1/3). Don't try to size the carousel itself.
  • Let Carousel.Content handle the gutter. The default overflow='subtle' applies -mx-4 px-4 sm:-mx-6 sm:px-6 plus fade-to-background gradients on the left and right edges, so slides visibly bleed past the viewport and fade into the page without leaving half-clipped cards. Use overflow='hidden' for a hard clip or overflow='visible' to let overflow extend indefinitely into surrounding margin.
  • Compose the Header instead of stacking divs. <Carousel.Header> accepts up to three children placed by source order: title, dots, controls. On mobile it collapses to a flex row with the controls hidden, so the dots automatically pin to the right.
  • Nav UI hides when it's not needed. Carousel.Controls, Carousel.Previous, Carousel.Next, and Carousel.Dots all render null when every slide already fits in the viewport. No special handling required at the consumer.
  • Use Carousel.PlayPause whenever autoPlay is set. WCAG 2.2.2 requires a persistent pause control for any auto-updating content. Place it first inside Carousel.Controls so it is the first tab stop.
  • Direct children only. Carousel.Content walks its children with Children.map to inject per-item context. Fragments and conditionally rendered children are not unwrapped. Render an array of Carousel.Item directly.
  • reInit is automatic. Adding or removing slides at runtime triggers an Embla re-init.
  • opts is a pass-through to Embla. Roadie sets opinionated defaults for align: 'start' and slidesToScroll: 'auto' (Embla's own defaults are center and 1, which read worse for card carousels). Consumers can override either via opts. Roadie defaults spread first, consumer opts second. The axis and duration options are managed by the direction and prefers-reduced-motion hook respectively and can't be overridden. See the full list of Embla options in the Embla Carousel options reference.

Accessibility

  • Region role. The carousel root has role='region' and aria-roledescription='carousel'. The accessible name comes from Carousel.Title / Carousel.TitleLink (via aria-labelledby) when present, otherwise from the aria-label prop on the root.
  • Slide group role. Each Carousel.Item has role='group', aria-roledescription='slide', and aria-label='N of M'. Non-active slides are marked inert so their interactive content stays out of the tab order.
  • Live region. Carousel.Content is aria-live='off' while autoplay is running and aria-live='polite' otherwise. Transient hover/focus pauses do not flip the live region. Only an explicit pause via Carousel.PlayPause does.
  • Keyboard. When the viewport has focus: ArrowLeft/ArrowRight (or ArrowUp/ArrowDown in vertical mode) move between slides; Home/End jump to the first/last slide. Arrow keys never hijack focus when the user is interacting with a focusable element inside a slide.
  • Pause behaviour. Autoplay pauses on hover, focus, and pointerdown. Explicit pause (via Carousel.PlayPause) is sticky. Autoplay does not auto-resume after a user click. WCAG 2.2.2 compliant.
  • Reduced motion. When prefers-reduced-motion: reduce is set, autoplay is disabled entirely (no plugin instantiated) and Embla's duration is set to 0 for instant transitions. Live changes to the OS preference take effect via a matchMedia change listener.

Hooks

useCarousel

function useCarousel(): { state: CarouselState; actions: CarouselActions }

Read state and call actions from a child component. Must be called inside a <Carousel> tree.

FieldTypeDescription
state.direction'horizontal' | 'vertical'The carousel's scroll axis.
state.selectedIndexnumberSelected snap index (0-based). A snap may cover multiple slides.
state.slideCountnumberNumber of Carousel.Item children.
state.snapCountnumberNumber of distinct Embla scroll snap positions.
state.canGoToPrevbooleanWhether goToPrev moves anywhere.
state.canGoToNextbooleanWhether goToNext moves anywhere.
state.canScrollbooleanFalse when every slide already fits in the viewport.
state.isPlayingbooleanAutoplay's current state.
state.userPausedbooleanTrue once the user explicitly pauses.
state.labelIdstring | undefinedId of the element supplying the accessible name.
state.rootAriaLabelstring | undefinedThe root's aria-label, when set directly.
actions.goToPrev() => voidMove to the previous snap.
actions.goToNext() => voidMove to the next snap.
actions.goTo(index: number) => voidMove to a specific snap.
actions.play() => voidStart or resume autoplay.
actions.pause() => voidPause autoplay.
actions.toggle() => voidToggle autoplay.

useCarouselUnsafeEmbla

import type { EmblaCarouselType } from 'embla-carousel'
function useCarouselUnsafeEmbla(): EmblaCarouselType | undefined

Escape hatch: returns the raw Embla api instance. Hard-couples your code to a specific embla-carousel major version, so prefer useCarousel() whenever its state and actions are sufficient. This hook exists so raw-Embla usage stays greppable when planning an upgrade.

API reference

Carousel

optsPartial<OptionsType>

Pass-through options for the Embla instance.

direction"horizontal" | "vertical"

Scroll direction.

Defaults to 'horizontal'.

autoPlaynumber | false

Autoplay delay in milliseconds, or `false` to disable. When set, an autoplay plugin is wired internally and pauses on hover / focus / pointer interaction. A persistent pause control via `<Carousel.PlayPause>` is strongly recommended for WCAG 2.2.2 compliance.

Defaults to false.

Carousel.Content

containerPropsDetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>

Props to forward to the inner Embla container (flex row/col).

overflow"hidden" | "visible" | "subtle"

How slides escape the viewport box. - `subtle` (default): slides bleed past the edges by the gutter width and fade to the page background via `::before` / `::after` gradients. Good for most carousels — gives a clear scroll hint without half-clipped cards. - `hidden`: slides are hard-clipped at the viewport edge. - `visible`: slides can extend indefinitely. Useful on wide screens where you deliberately want peeking slides to remain fully rendered in the surrounding margin area.

Defaults to subtle.

Carousel.Controls

forceVisibleboolean

Render the controls even when there's nothing to scroll to. Useful when the slot contains custom buttons (filters, share) that aren't gated on slide count.

Defaults to false.

Carousel.Dots

No additional props. It forwards all standard HTML attributes to the underlying element.

Carousel.Header

No additional props. It forwards all standard HTML attributes to the underlying element.

Carousel.Item

No additional props. It forwards all standard HTML attributes to the underlying element.

Carousel.Next

classNamestring | ((state: ButtonState) => string)

CSS class applied to the element, or a function that returns a class based on the component's state.

intent"neutral" | "brand" | "brand-secondary" | "brand-blue" | "brand-orange" | "brand-purple" | "accent" | "danger" | "success" | "warning" | "info" | null
emphasis"strong" | "subtle" | "normal" | "subtler" | null
size"xs" | "sm" | "md" | "lg" | "icon-xs" | "icon-sm" | "icon-md" | "icon-lg"

Icon-button sizing. Use `'xs' | 'sm' | 'md' | 'lg'` (plain) — the `'icon-*'` aliases are accepted for backwards compatibility but discouraged.

Defaults to 'md'.

hrefstring

Pass a URL to render the button as a routed anchor instead of a `<button>`. Internal hrefs route through the configured `RoadieLinkProvider` (or fall back to plain `<a>`); external hrefs (`http(s)://`, `//…`) render as `<a target='_blank' rel='noopener noreferrer'>`; `mailto:` / `tel:` / `sms:` render as plain `<a>`. Pair with `external`, `target`, or `rel` to override the defaults.

externalboolean

Force external-link treatment regardless of `href` shape. Useful for first-party URLs that should still open in a new tab, or for an `https://` URL that should route internally through the provider.

targetstring

Override the auto `target='_blank'` default on external hrefs.

downloadstring | boolean

Download the `href` instead of navigating, optionally as this filename.

aria-labelstring

Override the default accessible label. Defaults to "Previous slide" / "Next slide" / "Pause carousel" / "Play carousel".

Defaults to Next slide.

childrenReactNode

Override the default caret/play/pause icon.

Inherited from ButtonProps

focusableWhenDisabledboolean

Whether the button should be focusable when disabled.

Defaults to false.

Inherited from NativeButtonProps

nativeButtonboolean

Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`).

Defaults to true.

Carousel.PlayPause

classNamestring | ((state: ButtonState) => string)

CSS class applied to the element, or a function that returns a class based on the component's state.

intent"neutral" | "brand" | "brand-secondary" | "brand-blue" | "brand-orange" | "brand-purple" | "accent" | "danger" | "success" | "warning" | "info" | null
emphasis"strong" | "subtle" | "normal" | "subtler" | null
size"xs" | "sm" | "md" | "lg" | "icon-xs" | "icon-sm" | "icon-md" | "icon-lg"

Icon-button sizing. Use `'xs' | 'sm' | 'md' | 'lg'` (plain) — the `'icon-*'` aliases are accepted for backwards compatibility but discouraged.

Defaults to 'md'.

hrefstring

Pass a URL to render the button as a routed anchor instead of a `<button>`. Internal hrefs route through the configured `RoadieLinkProvider` (or fall back to plain `<a>`); external hrefs (`http(s)://`, `//…`) render as `<a target='_blank' rel='noopener noreferrer'>`; `mailto:` / `tel:` / `sms:` render as plain `<a>`. Pair with `external`, `target`, or `rel` to override the defaults.

externalboolean

Force external-link treatment regardless of `href` shape. Useful for first-party URLs that should still open in a new tab, or for an `https://` URL that should route internally through the provider.

targetstring

Override the auto `target='_blank'` default on external hrefs.

downloadstring | boolean

Download the `href` instead of navigating, optionally as this filename.

aria-labelstring

Override the default accessible label. Defaults to "Previous slide" / "Next slide" / "Pause carousel" / "Play carousel".

childrenReactNode

Override the default caret/play/pause icon.

Inherited from ButtonProps

focusableWhenDisabledboolean

Whether the button should be focusable when disabled.

Defaults to false.

Inherited from NativeButtonProps

nativeButtonboolean

Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`).

Defaults to true.

Carousel.Previous

classNamestring | ((state: ButtonState) => string)

CSS class applied to the element, or a function that returns a class based on the component's state.

intent"neutral" | "brand" | "brand-secondary" | "brand-blue" | "brand-orange" | "brand-purple" | "accent" | "danger" | "success" | "warning" | "info" | null
emphasis"strong" | "subtle" | "normal" | "subtler" | null
size"xs" | "sm" | "md" | "lg" | "icon-xs" | "icon-sm" | "icon-md" | "icon-lg"

Icon-button sizing. Use `'xs' | 'sm' | 'md' | 'lg'` (plain) — the `'icon-*'` aliases are accepted for backwards compatibility but discouraged.

Defaults to 'md'.

hrefstring

Pass a URL to render the button as a routed anchor instead of a `<button>`. Internal hrefs route through the configured `RoadieLinkProvider` (or fall back to plain `<a>`); external hrefs (`http(s)://`, `//…`) render as `<a target='_blank' rel='noopener noreferrer'>`; `mailto:` / `tel:` / `sms:` render as plain `<a>`. Pair with `external`, `target`, or `rel` to override the defaults.

externalboolean

Force external-link treatment regardless of `href` shape. Useful for first-party URLs that should still open in a new tab, or for an `https://` URL that should route internally through the provider.

targetstring

Override the auto `target='_blank'` default on external hrefs.

downloadstring | boolean

Download the `href` instead of navigating, optionally as this filename.

aria-labelstring

Override the default accessible label. Defaults to "Previous slide" / "Next slide" / "Pause carousel" / "Play carousel".

Defaults to Previous slide.

childrenReactNode

Override the default caret/play/pause icon.

Inherited from ButtonProps

focusableWhenDisabledboolean

Whether the button should be focusable when disabled.

Defaults to false.

Inherited from NativeButtonProps

nativeButtonboolean

Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`).

Defaults to true.

Carousel.Title

as"h1" | "h2" | "h3" | "h4" | "h5" | "h6"
Deprecated

Deprecated: Use `render` instead (e.g. `render={<h3 />}` to change the heading level). `as` will be removed in v3.0.0.

renderRoadieRenderProp

Escape hatch — render a different heading element/level with full control. The carousel's accessible-name registration is preserved.

Carousel.TitleLink

asElementType
Deprecated

Deprecated: Use `render` instead. `as` will be removed in v3.0.0.

hrefstring

Pass an href to render the title as a routed anchor. Internal hrefs route through the configured `RoadieLinkProvider`; external hrefs (`http(s)://`, `//…`) render `<a target='_blank' rel='noopener noreferrer'>`; `mailto:` / `tel:` / `sms:` render plain `<a>`.

externalboolean

Force external-link treatment when `href` is set.

targetstring

Override the auto `target='_blank'` default on external hrefs.

relstring

Override the auto `rel='noopener noreferrer'` default on external hrefs.

renderRoadieRenderProp

Escape hatch — swap the underlying element with full control over the rendered shape.

idstring

DOM id for `aria-labelledby`. Defaults to a generated id.

Previous page← CardNext pageList →