A compound horizontal or vertical carousel built on Embla, with built-in autoplay, keyboard navigation, and prefers-reduced-motion handling.
import { Carousel } from '@oztix/roadie-components/carousel'
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.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.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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
Pass direction='vertical' to flip the axis. The Previous/Next icons swap to up/down carets automatically.
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 is a compound component. Available parts:
| Part | Role |
|---|---|
Carousel | Root. Owns the Embla instance and context. |
Carousel.Header | Three-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.TitleLink | Anchor with a trailing arrow icon. Pass render={<Link />} to use a framework router link. Registers as the carousel's accessible name. |
Carousel.Controls | Inline 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.Content | Embla viewport + container. Wraps each direct Carousel.Item child in per-item context. |
Carousel.Item | A single slide. Set the width with Tailwind basis-* utilities. |
Carousel.Previous / Carousel.Next | Nav buttons (compose IconButton). Auto-hide when there's nothing to scroll to. |
Carousel.PlayPause | Play/pause toggle. Renders only when autoPlay is set. |
Carousel.Dots | One 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. |
Carousel.Item via Tailwind basis-* utilities (e.g. basis-1/2 md:basis-1/3). Don't try to size the carousel itself.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.<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.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.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.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.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.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.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.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.pointerdown. Explicit pause (via Carousel.PlayPause) is sticky. Autoplay does not auto-resume after a user click. WCAG 2.2.2 compliant.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.function useCarousel(): { state: CarouselState; actions: CarouselActions }
Read state and call actions from a child component. Must be called inside a <Carousel> tree.
| Field | Type | Description |
|---|---|---|
state.direction | 'horizontal' | 'vertical' | The carousel's scroll axis. |
state.selectedIndex | number | Selected snap index (0-based). A snap may cover multiple slides. |
state.slideCount | number | Number of Carousel.Item children. |
state.snapCount | number | Number of distinct Embla scroll snap positions. |
state.canGoToPrev | boolean | Whether goToPrev moves anywhere. |
state.canGoToNext | boolean | Whether goToNext moves anywhere. |
state.canScroll | boolean | False when every slide already fits in the viewport. |
state.isPlaying | boolean | Autoplay's current state. |
state.userPaused | boolean | True once the user explicitly pauses. |
state.labelId | string | undefined | Id of the element supplying the accessible name. |
state.rootAriaLabel | string | undefined | The root's aria-label, when set directly. |
actions.goToPrev | () => void | Move to the previous snap. |
actions.goToNext | () => void | Move to the next snap. |
actions.goTo | (index: number) => void | Move to a specific snap. |
actions.play | () => void | Start or resume autoplay. |
actions.pause | () => void | Pause autoplay. |
actions.toggle | () => void | Toggle autoplay. |
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.
Pass-through options for the Embla instance.
Scroll direction.
Defaults to 'horizontal'.
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.
Props to forward to the inner Embla container (flex row/col).
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.
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.
No additional props. It forwards all standard HTML attributes to the underlying element.
No additional props. It forwards all standard HTML attributes to the underlying element.
No additional props. It forwards all standard HTML attributes to the underlying element.
CSS class applied to the element, or a function that returns a class based on the component's state.
Icon-button sizing. Use `'xs' | 'sm' | 'md' | 'lg'` (plain) — the `'icon-*'` aliases are accepted for backwards compatibility but discouraged.
Defaults to 'md'.
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.
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.
Override the auto `target='_blank'` default on external hrefs.
Download the `href` instead of navigating, optionally as this filename.
Override the default accessible label. Defaults to "Previous slide" / "Next slide" / "Pause carousel" / "Play carousel".
Defaults to Next slide.
Override the default caret/play/pause icon.
Inherited from ButtonProps
Whether the button should be focusable when disabled.
Defaults to false.
Inherited from NativeButtonProps
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.
CSS class applied to the element, or a function that returns a class based on the component's state.
Icon-button sizing. Use `'xs' | 'sm' | 'md' | 'lg'` (plain) — the `'icon-*'` aliases are accepted for backwards compatibility but discouraged.
Defaults to 'md'.
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.
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.
Override the auto `target='_blank'` default on external hrefs.
Download the `href` instead of navigating, optionally as this filename.
Override the default accessible label. Defaults to "Previous slide" / "Next slide" / "Pause carousel" / "Play carousel".
Override the default caret/play/pause icon.
Inherited from ButtonProps
Whether the button should be focusable when disabled.
Defaults to false.
Inherited from NativeButtonProps
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.
CSS class applied to the element, or a function that returns a class based on the component's state.
Icon-button sizing. Use `'xs' | 'sm' | 'md' | 'lg'` (plain) — the `'icon-*'` aliases are accepted for backwards compatibility but discouraged.
Defaults to 'md'.
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.
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.
Override the auto `target='_blank'` default on external hrefs.
Download the `href` instead of navigating, optionally as this filename.
Override the default accessible label. Defaults to "Previous slide" / "Next slide" / "Pause carousel" / "Play carousel".
Defaults to Previous slide.
Override the default caret/play/pause icon.
Inherited from ButtonProps
Whether the button should be focusable when disabled.
Defaults to false.
Inherited from NativeButtonProps
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.
Deprecated: Use `render` instead (e.g. `render={<h3 />}` to change the heading level). `as` will be removed in v3.0.0.
Escape hatch — render a different heading element/level with full control. The carousel's accessible-name registration is preserved.
Deprecated: Use `render` instead. `as` will be removed in v3.0.0.
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>`.
Force external-link treatment when `href` is set.
Override the auto `target='_blank'` default on external hrefs.
Override the auto `rel='noopener noreferrer'` default on external hrefs.
Escape hatch — swap the underlying element with full control over the rendered shape.
DOM id for `aria-labelledby`. Defaults to a generated id.