# Carousel

> Compound horizontal or vertical carousel built on Embla

Status: stable. Added in Components 2.1.0. Updated in Components 2.16.0, 20 Oct 2026.

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

## Import

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

```tsx
<Carousel aria-label='Default carousel'>
  <Carousel.Header>
    <Carousel.Title>Upcoming shows</Carousel.Title>
    <Carousel.Dots />
    <Carousel.Controls>
      <Carousel.Previous />
      <Carousel.Next />
    </Carousel.Controls>
  </Carousel.Header>
  <Carousel.Content>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Midnight Frequency</Card.Title>
          <Card.Description>Clockwork Wattle Brewery, Northbridge</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Quokka Thunder Weekender</Card.Title>
          <Card.Description>Jumbuck Orchard Park, Eastdale</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Sapphire Cassowary Night</Card.Title>
          <Card.Description>Antler Kettle Hall</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Wave Theory</Card.Title>
          <Card.Description>Kazoo Hollow Room, Footscray</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Static Bloom</Card.Title>
          <Card.Description>Drongo Bell Social Club</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Echo Garden</Card.Title>
          <Card.Description>Saltbush Trumpet Ballroom</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
  </Carousel.Content>
</Carousel>
```

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

```tsx
<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'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Card one</Card.Title>
          <Card.Description>Fades into the page</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Card two</Card.Title>
          <Card.Description>Fades into the page</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Card three</Card.Title>
          <Card.Description>Fades into the page</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Card four</Card.Title>
          <Card.Description>Fades into the page</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Card five</Card.Title>
          <Card.Description>Fades into the page</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
  </Carousel.Content>
</Carousel>

<Carousel aria-label='Hidden overflow'>
  <Carousel.Header>
    <Carousel.Title>Hidden</Carousel.Title>
  </Carousel.Header>
  <Carousel.Content overflow='hidden'>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Card one</Card.Title>
          <Card.Description>Hard clip at the edge</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Card two</Card.Title>
          <Card.Description>Hard clip at the edge</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Card three</Card.Title>
          <Card.Description>Hard clip at the edge</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Card four</Card.Title>
          <Card.Description>Hard clip at the edge</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Card five</Card.Title>
          <Card.Description>Hard clip at the edge</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
  </Carousel.Content>
</Carousel>

<Carousel aria-label='Visible overflow'>
  <Carousel.Header>
    <Carousel.Title>Visible</Carousel.Title>
  </Carousel.Header>
  <Carousel.Content overflow='visible'>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Card one</Card.Title>
          <Card.Description>Extends past the edge</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Card two</Card.Title>
          <Card.Description>Extends past the edge</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Card three</Card.Title>
          <Card.Description>Extends past the edge</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Card four</Card.Title>
          <Card.Description>Extends past the edge</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Card five</Card.Title>
          <Card.Description>Extends past the edge</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
  </Carousel.Content>
</Carousel>
```

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

```tsx
<Carousel aria-label='Linked event cards'>
  <Carousel.Header>
    <Carousel.TitleLink href='/events'>Browse all events</Carousel.TitleLink>
    <Carousel.Dots />
    <Carousel.Controls>
      <Carousel.Previous />
      <Carousel.Next />
    </Carousel.Controls>
  </Carousel.Header>
  <Carousel.Content>
    <Carousel.Item className='basis-[78%] md:basis-1/3'>
      <Card emphasis='raised' href='/events/midnight-frequency'>
        <Card.Content>
          <Card.Title>Midnight Frequency</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-03-14T09:30:00Z')}
              timeZone='Australia/Brisbane'
            />{' '}
            &middot; Clockwork Wattle Brewery
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[78%] md:basis-1/3'>
      <Card emphasis='raised' href='/events/quokka-thunder-weekender'>
        <Card.Content>
          <Card.Title>Quokka Thunder Weekender</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-03-22T09:30:00Z')}
              timeZone='Australia/Brisbane'
            />{' '}
            &middot; Jumbuck Orchard Park
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[78%] md:basis-1/3'>
      <Card emphasis='raised' href='/events/sapphire-cassowary-night'>
        <Card.Content>
          <Card.Title>Sapphire Cassowary Night</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-03-30T09:30:00Z')}
              timeZone='Australia/Brisbane'
            />{' '}
            &middot; Antler Kettle Hall
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[78%] md:basis-1/3'>
      <Card emphasis='raised' href='/events/wave-theory'>
        <Card.Content>
          <Card.Title>Wave Theory</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-04-05T09:30:00Z')}
              timeZone='Australia/Brisbane'
            />{' '}
            &middot; Kazoo Hollow Room
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
  </Carousel.Content>
</Carousel>
```

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

```tsx
<Carousel aria-label='Events with artwork'>
  <Carousel.Header>
    <Carousel.TitleLink href='/events'>What's on</Carousel.TitleLink>
    <Carousel.Controls>
      <Carousel.Previous />
      <Carousel.Next />
    </Carousel.Controls>
  </Carousel.Header>
  <Carousel.Content>
    {[
      'Midnight Frequency',
      'Quokka Thunder Weekender',
      'Sapphire Cassowary Night',
      'Wave Theory',
      'Echo Chamber',
      'Lowlight'
    ].map((title, i) => (
      <Carousel.Item key={title} className='basis-[78%] md:basis-1/3'>
        <Card emphasis='raised' href={`/events/${i}`}>
          <Card.Image
            src='https://assets.oztix.com.au/image/1226ab55-3d53-47f0-ab4c-cddcf02bb001.png'
            alt={title}
            width={360}
            height={203}
            sizes='(min-width: 768px) 33vw, 78vw'
            defer
            placeholder='blur'
            priority={i === 0}
          />
          <Card.Content>
            <Card.Title>{title}</Card.Title>
            <Card.Description>
            <DateTime
              at={new Date('2026-03-14T09:30:00Z')}
              timeZone='Australia/Brisbane'
            />{' '}
            &middot; Clockwork Wattle Brewery
          </Card.Description>
          </Card.Content>
        </Card>
      </Carousel.Item>
    ))}
  </Carousel.Content>
</Carousel>
```

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

```tsx
<Carousel aria-label='Events by page'>
  <Carousel.Header>
    <Carousel.Title>This weekend</Carousel.Title>
    <Carousel.Dots />
    <Carousel.Controls>
      <Carousel.Previous />
      <Carousel.Next />
    </Carousel.Controls>
  </Carousel.Header>
  <Carousel.Content>
    <Carousel.Item className='basis-[42%] md:basis-1/3 lg:basis-1/4'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Midnight Frequency</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-03-14T09:30:00Z')}
              timeZone='Australia/Brisbane'
            />{' '}
            &middot; Clockwork Wattle Brewery
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3 lg:basis-1/4'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Quokka Thunder Weekender</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-03-22T09:30:00Z')}
              timeZone='Australia/Brisbane'
            />{' '}
            &middot; Jumbuck Orchard Park
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3 lg:basis-1/4'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Sapphire Cassowary Night</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-03-30T09:30:00Z')}
              timeZone='Australia/Brisbane'
            />{' '}
            &middot; Antler Kettle Hall
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3 lg:basis-1/4'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Wave Theory</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-04-05T09:30:00Z')}
              timeZone='Australia/Brisbane'
            />{' '}
            &middot; Kazoo Hollow Room
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3 lg:basis-1/4'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Static Bloom</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-04-12T09:30:00Z')}
              timeZone='Australia/Brisbane'
            />{' '}
            &middot; Drongo Bell Social Club
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3 lg:basis-1/4'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Echo Garden</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-04-20T09:30:00Z')}
              timeZone='Australia/Brisbane'
            />{' '}
            &middot; Saltbush Trumpet Ballroom
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3 lg:basis-1/4'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Velvet Hours</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-04-25T09:30:00Z')}
              timeZone='Australia/Brisbane'
            />{' '}
            &middot; The Quilted Walrus Room
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[42%] md:basis-1/3 lg:basis-1/4'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Paper Lanterns</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-05-03T09:30:00Z')}
              timeZone='Australia/Brisbane'
            />{' '}
            &middot; Foxglove Tin Pavilion
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
  </Carousel.Content>
</Carousel>
```

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.

```tsx
<Carousel
  aria-label='Step through each story'
  opts={{ slidesToScroll: 1 }}
>
  <Carousel.Header>
    <Carousel.Title>Tour stories</Carousel.Title>
    <Carousel.Dots />
    <Carousel.Controls>
      <Carousel.Previous />
      <Carousel.Next />
    </Carousel.Controls>
  </Carousel.Header>
  <Carousel.Content>
    <Carousel.Item className='basis-[82%] md:basis-1/2'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Backstage at Clockwork Wattle Brewery</Card.Title>
          <Card.Description>Notes from the first night of Velvet Hours: sound checks, setlist changes, and the unexpected encore.</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[82%] md:basis-1/2'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>On the road with Sapphire Cassowary Night</Card.Title>
          <Card.Description>Three cities, two tour buses, and the long conversations at 2am about synths and setlists.</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[82%] md:basis-1/2'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Recording Echo Garden live</Card.Title>
          <Card.Description>How a Sunday matinee at Echidna Gaslight Theatre turned into a live album.</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[82%] md:basis-1/2'>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Wave Theory's rider</Card.Title>
          <Card.Description>A deep look at the tech, the tea selection, and why the keyboardist travels with a houseplant.</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
  </Carousel.Content>
</Carousel>
```

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](https://www.embla-carousel.com/api/options/).

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

```tsx
<Carousel aria-label='Browse by genre'>
  <Carousel.Header>
    <Carousel.Title>Browse by genre</Carousel.Title>
    <Carousel.Dots />
    <Carousel.Controls>
      <Carousel.Previous />
      <Carousel.Next />
    </Carousel.Controls>
  </Carousel.Header>
  <Carousel.Content>
    <Carousel.Item className='basis-auto'>
      <Card href='/genres/rock'>
        <Card.Content>
          <Card.Title>Rock</Card.Title>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-auto'>
      <Card href='/genres/comedy'>
        <Card.Content>
          <Card.Title>Comedy</Card.Title>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-auto'>
      <Card href='/genres/electronic'>
        <Card.Content>
          <Card.Title>Electronic</Card.Title>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-auto'>
      <Card href='/genres/hip-hop'>
        <Card.Content>
          <Card.Title>Hip Hop</Card.Title>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-auto'>
      <Card href='/genres/folk'>
        <Card.Content>
          <Card.Title>Folk &amp; Acoustic</Card.Title>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-auto'>
      <Card href='/genres/jazz'>
        <Card.Content>
          <Card.Title>Jazz</Card.Title>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-auto'>
      <Card href='/genres/classical'>
        <Card.Content>
          <Card.Title>Classical</Card.Title>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-auto'>
      <Card href='/genres/country'>
        <Card.Content>
          <Card.Title>Country</Card.Title>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-auto'>
      <Card href='/genres/rnb'>
        <Card.Content>
          <Card.Title>R&amp;B and Soul</Card.Title>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-auto'>
      <Card href='/genres/indie'>
        <Card.Content>
          <Card.Title>Indie</Card.Title>
        </Card.Content>
      </Card>
    </Carousel.Item>
  </Carousel.Content>
</Carousel>
```

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

```tsx
<Carousel
  aria-label='Featured events'
  autoPlay={5000}
  opts={{ loop: true }}
>
  <Carousel.Header>
    <Carousel.TitleLink href='/events'>
      Featured events
    </Carousel.TitleLink>
    <Carousel.Dots />
    <Carousel.Controls>
      <Carousel.PlayPause />
      <Carousel.Previous />
      <Carousel.Next />
    </Carousel.Controls>
  </Carousel.Header>
  <Carousel.Content>
    <Carousel.Item className='basis-[88%] md:basis-1/2'>
      <Card emphasis='raised' href='/events/midnight-frequency'>
        <Card.Content>
          <Card.Title>Midnight Frequency</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-03-14T09:30:00Z')}
              timeZone='Australia/Brisbane'
            />{' '}
            &middot; Clockwork Wattle Brewery
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[88%] md:basis-1/2'>
      <Card emphasis='raised' href='/events/quokka-thunder-weekender'>
        <Card.Content>
          <Card.Title>Quokka Thunder Weekender</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-03-22T09:30:00Z')}
              timeZone='Australia/Brisbane'
            />{' '}
            &middot; Jumbuck Orchard Park
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[88%] md:basis-1/2'>
      <Card emphasis='raised' href='/events/sapphire-cassowary-night'>
        <Card.Content>
          <Card.Title>Sapphire Cassowary Night</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-03-30T09:30:00Z')}
              timeZone='Australia/Brisbane'
            />{' '}
            &middot; Antler Kettle Hall
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
  </Carousel.Content>
</Carousel>
```

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

```tsx
<Carousel aria-label='Comedy lineup'>
  <Carousel.Header>
    <Carousel.TitleLink href='/comedy'>Comedy</Carousel.TitleLink>
    <Carousel.Dots />
    <Carousel.Controls>
      <Carousel.Previous />
      <Carousel.Next />
    </Carousel.Controls>
  </Carousel.Header>
  <Carousel.Content>
    <Carousel.Item className='basis-[78%] md:basis-1/3'>
      <Card emphasis='raised' href='/events/eli-bramble'>
        <Card.Content>
          <Card.Title>Eli Bramble: Punch Lines</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-06-29T11:00:00Z')}
              timeZone='Australia/Brisbane'
              timeStyle='medium'
            />
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[78%] md:basis-1/3'>
      <Card emphasis='raised' href='/events/giggling-lantern-comedy-gala'>
        <Card.Content>
          <Card.Title>Giggling Lantern Comedy Gala for Very Serious People</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-07-03T10:15:00Z')}
              timeZone='Australia/Brisbane'
              timeStyle='medium'
            />
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item className='basis-[78%] md:basis-1/3'>
      <Card emphasis='raised' href='/events/casey-vale'>
        <Card.Content>
          <Card.Title>Casey Vale: Maybe Tomorrow</Card.Title>
          <Card.Description>
            <DateTime
              at={new Date('2026-07-05T06:45:00Z')}
              timeZone='Australia/Brisbane'
              timeStyle='medium'
            />
          </Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
  </Carousel.Content>
</Carousel>
```

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

```tsx
<Carousel aria-label='With dots'>
  <Carousel.Header>
    <Carousel.Title>Slides</Carousel.Title>
    <Carousel.Dots />
  </Carousel.Header>
  <Carousel.Content>
    <Carousel.Item>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Slide one</Card.Title>
          <Card.Description>Use the dots to navigate.</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Slide two</Card.Title>
          <Card.Description>Each dot scrolls to its slide.</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Slide three</Card.Title>
          <Card.Description>Active dot is highlighted.</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
  </Carousel.Content>
</Carousel>
```

### Vertical

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

```tsx
<Carousel aria-label='Vertical carousel' direction='vertical' className='h-64'>
  <Carousel.Header>
    <Carousel.Title>Vertical</Carousel.Title>
    <Carousel.Controls>
      <Carousel.Previous />
      <Carousel.Next />
    </Carousel.Controls>
  </Carousel.Header>
  <Carousel.Content className='h-48'>
    <Carousel.Item>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>First</Card.Title>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Second</Card.Title>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Third</Card.Title>
        </Card.Content>
      </Card>
    </Carousel.Item>
  </Carousel.Content>
</Carousel>
```

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

```tsx
<Carousel aria-label='State demo' autoPlay={5000}>
  <Carousel.Header>
    <Carousel.Title>Boundary + autoplay states</Carousel.Title>
    <Carousel.Dots />
    <Carousel.Controls>
      <Carousel.PlayPause />
      <Carousel.Previous />
      <Carousel.Next />
    </Carousel.Controls>
  </Carousel.Header>
  <Carousel.Content>
    <Carousel.Item>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>First slide</Card.Title>
          <Card.Description>Previous is disabled, try it.</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Middle slide</Card.Title>
          <Card.Description>Both buttons enabled.</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
    <Carousel.Item>
      <Card emphasis='subtle'>
        <Card.Content>
          <Card.Title>Last slide</Card.Title>
          <Card.Description>Next is disabled.</Card.Description>
        </Card.Content>
      </Card>
    </Carousel.Item>
  </Carousel.Content>
</Carousel>
```

### Composition

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

## 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](https://www.embla-carousel.com/api/options/).

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

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

### useCarouselUnsafeEmbla

```tsx
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

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

- `opts`: `Partial<OptionsType>`. Pass-through options for the Embla instance.
- `direction`: `"horizontal" | "vertical"`. Defaults to `'horizontal'`. Scroll direction.
- `autoPlay`: `number | false`. Defaults to `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.

### Carousel.Content

- `containerProps`: `DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>`. Props to forward to the inner Embla container (flex row/col).
- `overflow`: `"hidden" | "visible" | "subtle"`. Defaults to `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.

### Carousel.Controls

- `forceVisible`: `boolean`. Defaults to `false`. 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.

### Carousel.Dots

No props beyond the standard HTML attributes.

### Carousel.Header

No props beyond the standard HTML attributes.

### Carousel.Item

No props beyond the standard HTML attributes.

### Carousel.Next

- `className`: `string | ((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"`. Defaults to `'md'`. Icon-button sizing. Use `'xs' | 'sm' | 'md' | 'lg'` (plain) — the `'icon-*'` aliases are accepted for backwards compatibility but discouraged.
- `focusableWhenDisabled`: `boolean`. Defaults to `false`. Whether the button should be focusable when disabled.
- `nativeButton`: `boolean`. Defaults to `true`. 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>`).
- `href`: `string`. 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.
- `external`: `boolean`. 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.
- `target`: `string`. Override the auto `target='_blank'` default on external hrefs.
- `download`: `string | boolean`. Download the `href` instead of navigating, optionally as this filename.
- `aria-label`: `string`. Defaults to `Next slide`. Override the default accessible label. Defaults to "Previous slide" / "Next slide" / "Pause carousel" / "Play carousel".
- `children`: `ReactNode`. Override the default caret/play/pause icon.

### Carousel.PlayPause

- `className`: `string | ((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"`. Defaults to `'md'`. Icon-button sizing. Use `'xs' | 'sm' | 'md' | 'lg'` (plain) — the `'icon-*'` aliases are accepted for backwards compatibility but discouraged.
- `focusableWhenDisabled`: `boolean`. Defaults to `false`. Whether the button should be focusable when disabled.
- `nativeButton`: `boolean`. Defaults to `true`. 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>`).
- `href`: `string`. 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.
- `external`: `boolean`. 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.
- `target`: `string`. Override the auto `target='_blank'` default on external hrefs.
- `download`: `string | boolean`. Download the `href` instead of navigating, optionally as this filename.
- `aria-label`: `string`. Override the default accessible label. Defaults to "Previous slide" / "Next slide" / "Pause carousel" / "Play carousel".
- `children`: `ReactNode`. Override the default caret/play/pause icon.

### Carousel.Previous

- `className`: `string | ((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"`. Defaults to `'md'`. Icon-button sizing. Use `'xs' | 'sm' | 'md' | 'lg'` (plain) — the `'icon-*'` aliases are accepted for backwards compatibility but discouraged.
- `focusableWhenDisabled`: `boolean`. Defaults to `false`. Whether the button should be focusable when disabled.
- `nativeButton`: `boolean`. Defaults to `true`. 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>`).
- `href`: `string`. 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.
- `external`: `boolean`. 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.
- `target`: `string`. Override the auto `target='_blank'` default on external hrefs.
- `download`: `string | boolean`. Download the `href` instead of navigating, optionally as this filename.
- `aria-label`: `string`. Defaults to `Previous slide`. Override the default accessible label. Defaults to "Previous slide" / "Next slide" / "Pause carousel" / "Play carousel".
- `children`: `ReactNode`. Override the default caret/play/pause icon.

### Carousel.Title

- `as`: `"h1" | "h2" | "h3" | "h4" | "h5" | "h6"`. Deprecated: Use `render` instead (e.g. `render={<h3 />}` to change the heading level). `as` will be removed in v3.0.0.
- `render`: `RoadieRenderProp`. Escape hatch — render a different heading element/level with full control. The carousel's accessible-name registration is preserved.

### Carousel.TitleLink

- `as`: `ElementType`. Deprecated: Use `render` instead. `as` will be removed in v3.0.0.
- `href`: `string`. 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>`.
- `external`: `boolean`. Force external-link treatment when `href` is set.
- `target`: `string`. Override the auto `target='_blank'` default on external hrefs.
- `rel`: `string`. Override the auto `rel='noopener noreferrer'` default on external hrefs.
- `render`: `RoadieRenderProp`. Escape hatch — swap the underlying element with full control over the rendered shape.
- `id`: `string`. DOM id for `aria-labelledby`. Defaults to a generated id.

Docs updated 20 Oct 2026.
