# Carousel

> A horizontal scroll-snap track for browsing slides. Native scroll and swipe work with zero JS; arrows, dots, and autoplay are progressive enhancement.

URL: https://apogee.avaui.dev/docs/carousel
Last updated: 2026-10-15

A horizontal scroll-snap track for browsing slides. Native scroll and swipe work with zero JS; arrows, dots, and autoplay are progressive enhancement.

Last updated October 15, 2026

Uses JavaScript

Wires the prev/next arrow buttons (scroll by 80% of the track width), the pagination dots (click to scroll to a slide, plus an `IntersectionObserver` that marks the active dot as you scroll), and autoplay (advances on a timer, pauses on hover/focus, and never starts under `prefers-reduced-motion`).

**Without JS:** The track scrolls fine without it — native scroll-snap, touch swipe, and (once focused) arrow-key scrolling all work. Only the arrow buttons and dots disappear (\`no-js:hidden\`).

```astro
<Carousel itemWidth="14rem">
<div class="rounded-lg border border-neutral bg-neutral-faded p-6">Slide 1</div>
<div class="rounded-lg border border-neutral bg-neutral-faded p-6">Slide 2</div>
<div class="rounded-lg border border-neutral bg-neutral-faded p-6">Slide 3</div>
<div class="rounded-lg border border-neutral bg-neutral-faded p-6">Slide 4</div>
</Carousel>
```

## Add

```sh
npx @ava-ui/apogee add carousel
```

No Apogee dependencies. Uses `@lucide/astro` for the prev/next chevrons.

## Usage

```astro
---
import Carousel from '@/components/apogee/Carousel/Carousel.astro'
---
<Carousel itemWidth="16rem" dots>
  <div>Slide 1</div>
  <div>Slide 2</div>
  <div>Slide 3</div>
</Carousel>
```

## Anatomy

-   **Track** — the scrollable, `scroll-snap-x` element holding your slides (the default slot). Always present, always usable, never gated behind JS.
-   **Prev / Next buttons** (optional, `controls`) — absolutely positioned arrow buttons that scroll the track. A progressive-enhancement island: `no-js:hidden`.
-   **Dots** (optional, `dots`) — one pagination dot per top-level slide, in a `role="group"`. Also an island: `no-js:hidden`. Dot count is derived by parsing the slotted markup at build time, not passed as a prop.

## API

`@composable` — exposed to Content Editors in AVA. Any other native `<div>` attribute passes through to the root.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `controls` | `boolean` | `true` | Show the prev/next arrow buttons. |
| `dots` | `boolean` | `false` | Show clickable pagination dots (one per slide). |
| `align` | `'start' \| 'center'` | `'start'` | Snap edge of each slide. |
| `itemWidth` | `string` | — | Fixed width applied to every slide (e.g. `"16rem"`). Omit to size each slide yourself. |
| `autoplay` | `boolean` | `false` | Auto-advance. Pauses on hover/focus; never runs under `prefers-reduced-motion`. |
| `interval` | `number` | `4000` | Autoplay delay in ms. |
| `loop` | `boolean` | `false` | With autoplay, jump back to the start after the last slide. |
| `label` | `string` | `'Carousel'` | Accessible name of the carousel region. |
| `controlsVariant` | `'default' \| 'overlay'` | `'default'` | `overlay`: borderless translucent arrows for image carousels. |
| `dotsPosition` | `'below' \| 'overlay'` | `'below'` | Dots under the track, or in a pill over its bottom edge. |
| `controlsPosition` | `'sides' \| 'below'` | `'sides'` | Arrows on the track’s sides, or in a row under it with the dots between them. |
| `visibleItems` | `Responsive<1 \| 2 \| 3 \| 4 \| 5 \| 6>` | — | Slides per view, per breakpoint (`{ base: 1, md: 3 }`); sizes every slide to fit, gap included. Overrides `itemWidth`. |
| `gap` | `2 \| 3 \| 4 \| 6 \| 8` | `4` | Space between slides, in spacing steps. |
| `bleed` | `boolean` | `false` | Let the track run under the page gutters. |
| `class` | `string` | — | Extra classes on the root, merged last. |

Slides go in the default slot, one top-level element per slide. Give each slide its own width, or set `itemWidth` for uniform slides.

## Variants

Snap alignment — `start` (default) vs `center`:

```astro
<Carousel itemWidth="10rem" align="start">
<div class="rounded-lg border border-neutral bg-neutral-faded p-6">A</div>
<div class="rounded-lg border border-neutral bg-neutral-faded p-6">B</div>
<div class="rounded-lg border border-neutral bg-neutral-faded p-6">C</div>
</Carousel>
<Carousel itemWidth="10rem" align="center">
<div class="rounded-lg border border-neutral bg-neutral-faded p-6">A</div>
<div class="rounded-lg border border-neutral bg-neutral-faded p-6">B</div>
<div class="rounded-lg border border-neutral bg-neutral-faded p-6">C</div>
</Carousel>
```

## Overlay controls

For image carousels: borderless translucent arrows and dots floating over the bottom edge. Arrows at an edge are disabled, not removed, so nothing shifts.

```astro
<Carousel visibleItems={1} controlsVariant="overlay" dots dotsPosition="overlay" label="Gallery">
<img src="…" alt="…" class="aspect-video w-full rounded-lg object-cover" />
…
</Carousel>
```

## Controls below

Put the arrows in a row under the slides when they would cover content, such as a quote. With `dots`, the dots sit between the arrows; without, the arrows sit at the end.

```astro
<Carousel visibleItems={1} controlsPosition="below" dots label="Reviews">
<blockquote>…</blockquote>
…
</Carousel>
<Carousel visibleItems={1} controlsPosition="below" label="Reviews">…</Carousel>
```

## Visible items, gap and bleed

```astro
<Carousel visibleItems={{ base: 1, md: 3 }} gap={4}>…</Carousel>
<Carousel visibleItems={2} gap={2}>…</Carousel>
… gap 3, 6, 8 …
<Carousel bleed itemWidth="14rem">…</Carousel>
```

## States

-   **Hover / focus** — arrow buttons and dots lighten/highlight on hover; a visible `focus-visible` ring (`--border-primary`) on keyboard focus.
-   **Active dot** — the dot for the in-view slide gets `aria-current="true"` and an accent fill; tracked live via `IntersectionObserver` as the user scrolls (not just on click).
-   **No JS** — arrows and dots are hidden (`no-js:hidden`); the track itself has no disabled state, it’s always scrollable.

## Examples

With pagination dots:

```astro
<Carousel itemWidth="10rem" dots>
<div class="rounded-lg border border-neutral bg-neutral-faded p-6">1</div>
<div class="rounded-lg border border-neutral bg-neutral-faded p-6">2</div>
<div class="rounded-lg border border-neutral bg-neutral-faded p-6">3</div>
</Carousel>
```

Autoplay, looping, no arrow controls:

```astro
<Carousel itemWidth="10rem" controls={false} autoplay loop interval={2500}>
<div class="rounded-lg border border-neutral bg-neutral-faded p-6">1</div>
<div class="rounded-lg border border-neutral bg-neutral-faded p-6">2</div>
<div class="rounded-lg border border-neutral bg-neutral-faded p-6">3</div>
</Carousel>
```

Self-sized slides (no `itemWidth` — each slide sets its own width):

```astro
<Carousel>
<div class="w-40 rounded-lg border border-neutral bg-neutral-faded p-6">Narrow</div>
<div class="w-64 rounded-lg border border-neutral bg-neutral-faded p-6">Wide</div>
<div class="w-40 rounded-lg border border-neutral bg-neutral-faded p-6">Narrow</div>
</Carousel>
```

On a brand fill (`bg-primary`), the current dot switches to the on-primary colour so it still stands out:

```astro
<div class="bg-primary text-on-primary">
<Carousel visibleItems={1} dots controlsPosition="below" label="What's new">…</Carousel>
</div>
```

## Accessibility

| Key | Action |
| --- | --- |
| `Tab` | Moves focus to the track, then to Previous / Next and each dot. |
| `←` `→` | Scroll the focused track. |
| `Enter` `Space` | Activate the focused Previous / Next button or dot. |

-   The track is a `region` with `aria-roledescription="carousel"`; each slide gets `aria-roledescription="slide"` and an `n of N` label.
    
-   Arrows are 44px targets. At the first and last slide the arrow disables in place and focus moves to the other arrow; the track scrolls by touch and, once focused, by arrow keys.
    
-   The root dispatches `apogee:carousel-change` (`detail: { index }`) when the visible slide changes.
    
-   Prev/Next buttons carry `aria-label="Previous"` / `aria-label="Next"`.
    
-   Dots sit in a `role="group"` (`aria-label="Slides"`); each dot button has `aria-label="Go to slide N"` and `aria-current` reflecting the in-view slide.
    
-   **No JS** — arrows and dots are hidden entirely (`no-js:hidden`), not shown disabled; nothing is lost, since the track scrolls by touch, trackpad, or focused arrow keys regardless.
    
-   **RTL** — under `dir="rtl"` the track runs right-to-left; Next scrolls toward the start edge and the arrow icons flip, so the controls read correctly.
    
-   Autoplay pauses on hover and on focus, and never starts under `prefers-reduced-motion: reduce`.
    

## Tokens

| Token | Used for |
| --- | --- |
| `--border-neutral` | Arrow button border (default controls) |
| `--background-neutral` | Inactive dot fill |
| `--background-page` | Arrow button background |
| `--foreground-neutral` | Arrow icon color |
| `--background-page-faded` | Arrow button hover background |
| `--background-primary` | Active dot fill |
| `--outline-color-focus` | Focus ring on arrows and dots |

## Do / Don’t

-   **Do** set `itemWidth` for uniform slides, or size each slide yourself for a mixed-width track.
-   **Do** enable `dots` when there are few enough slides that a dot-per-slide stays scannable.
-   **Don’t** put content a user _must_ see behind autoplay — they may miss a slide, and no-JS users never get it advancing at all.
-   **Don’t** rely on the carousel as the only path to its content if any slide holds critical, unique information — link to it elsewhere too.

## Content

-   Keep slide content self-contained — a user may land on any slide first (via a dot or a scroll), not always the first one.
-   Alt text on slide images should describe the image, not “slide 1 of 4” — the dots already convey position.

## Related

[

ProgressIndicatorStep N of M as a row of segments, for onboarding flows, wizards and carousels. Zero JS.

](https://apogee.avaui.dev/docs/progress-indicator)[

ScrollAreaA scroll container with a thin, token-coloured scrollbar.

](https://apogee.avaui.dev/docs/scroll-area)[

MarqueeAn endless scrolling strip for logos, tags, or testimonials, with a play/pause toggle.

](https://apogee.avaui.dev/docs/marquee)
