Carousel
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
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`).
Show codeHide code
<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
npx @ava-ui/apogee add carousel
No Apogee dependencies. Uses @lucide/astro for the prev/next chevrons.
Usage
---
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-xelement 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 arole="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:
Show codeHide code
<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.
Show codeHide code
<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.
Show codeHide code
<Carousel visibleItems={1} controlsPosition="below" dots label="Reviews">
<blockquote>…</blockquote>
…
</Carousel>
<Carousel visibleItems={1} controlsPosition="below" label="Reviews">…</Carousel>Visible items, gap and bleed
Show codeHide code
<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-visiblering (--border-primary) on keyboard focus. - Active dot — the dot for the in-view slide gets
aria-current="true"and an accent fill; tracked live viaIntersectionObserveras 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:
Show codeHide code
<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:
Show codeHide code
<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):
Show codeHide code
<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:
Show codeHide code
<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
regionwitharia-roledescription="carousel"; each slide getsaria-roledescription="slide"and ann of Nlabel. -
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 hasaria-label="Go to slide N"andaria-currentreflecting 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
itemWidthfor uniform slides, or size each slide yourself for a mixed-width track. - Do enable
dotswhen 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.