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

Show 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 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 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 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-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:

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