General · astro-ui · Carousel
CarouselJS
Accessible scroll-snap slide deck with buttons, counter, drag, and vertical layout.
Carousel is a native scroll container with CSS scroll-snap — scrolling IS the interaction, so touch, trackpad, and drag all work with no positioning math. Prev/next buttons and an optional counter ride the header row; a small scoped script syncs the active slide on scroll, wires buttons, and adds ArrowLeft/ArrowRight (↑/↓ vertical) navigation with optional loop wrap. Slides are CarouselSlide wrappers around any content, each announced as “x of y”. variant=“multiple” shows a peek layout sized via slideBasis. A bubbling carousel:change event fires on move, and carousel:set ({index} or {action}) drives it programmatically.
Features
- Native scroll-snap: touch, trackpad, and drag work with zero positioning code
- Prev/next buttons, optional “1 / 5” counter, optional loop wrap
- Overlay prev/next arrows floating on the slide edges (controlsPlacement="overlay")
- Dot indicators under the deck — click to jump, active dot tracks the scroll
- Per-slide captions (title + description) overlaid on the slide's lower edge
- Autoplay with a configurable interval; pauses on hover and focus
- Keyboard navigation when the scroll container is focused; slides announced as “x of y”
- Horizontal and vertical orientations; single and multiple (peek) variants
- Programmatic API via carousel:set; carousel:change fires on every move
- snapType mandatory/proximity, custom gap and slide basis
Usage
---
import { Carousel, CarouselSlide } from "astro-ui";
---
<Carousel label="Featured cards">
<CarouselSlide><div class="card">1</div></CarouselSlide>
<CarouselSlide><div class="card">2</div></CarouselSlide>
<CarouselSlide><div class="card">3</div></CarouselSlide>
</Carousel>Demos
Horizontal single
1 /
<Carousel label="Horizontal single cards" style="inline-size:min(100%,28rem)">
<CarouselSlide><div class="aui-carousel-demo-card">1</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card">2</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card">3</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card">4</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card">5</div></CarouselSlide>
</Carousel>Horizontal multiple (peek)
<Carousel
label="Horizontal multiple cards"
variant="multiple"
slideBasis="50%"
showCounter={false}
style="inline-size:min(100%,34rem)"
>
<CarouselSlide><div class="aui-carousel-demo-card">1</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card">2</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card">3</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card">4</div></CarouselSlide>
</Carousel>
<!-- slideBasis sizes each slide; gap sets the space between -->Vertical single
<Carousel
label="Vertical cards"
orientation="vertical"
showCounter={false}
contentClass="block-size:14rem"
style="inline-size:min(100%,24rem)"
>
<CarouselSlide><div class="aui-carousel-demo-card" style="block-size:100%">1</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card" style="block-size:100%">2</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card" style="block-size:100%">3</div></CarouselSlide>
</Carousel>
<!-- give the content a fixed block-size; slides fill it -->Looping
1 /
Next past the last slide wraps to the first; the counter and carousel:change event stay in sync.
<Carousel label="Looping cards" loop style="inline-size:min(100%,28rem)">
<CarouselSlide><div class="aui-carousel-demo-card">1</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card">2</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card">3</div></CarouselSlide>
</Carousel>
<script>
// Programmatic API + change event:
const c = document.getElementById("my-carousel");
c.addEventListener("carousel:change", (e) => console.log(e.detail.index));
c.dispatchEvent(new CustomEvent("carousel:set", {detail: {action: "next"}}));
<\/script>Overlay arrows
controlsPlacement="overlay" floats the prev/next arrows on the left and right edges of the slides.
<Carousel
label="Overlay arrow cards"
controlsPlacement="overlay"
showCounter={false}
loop
style="inline-size:min(100%,28rem)"
>
<CarouselSlide><div class="aui-carousel-demo-card">1</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card">2</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card">3</div></CarouselSlide>
</Carousel>
<!-- controlsPlacement="overlay" floats the arrows on the slide edges -->With indicators
One dot per slide — click to jump; the active dot tracks scrolling in both directions.
<Carousel
label="Indicator cards"
indicators
showCounter={false}
loop
style="inline-size:min(100%,28rem)"
>
<CarouselSlide><div class="aui-carousel-demo-card">1</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card">2</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card">3</div></CarouselSlide>
</Carousel>
<!-- indicators render one dot per slide; click jumps, the active
dot tracks scrolling in both directions -->With captions
caption + captionDescription render a translucent overlay bar across the slide's lower edge.
<Carousel
label="Captioned slides"
controlsPlacement="overlay"
indicators
showCounter={false}
loop
style="inline-size:min(100%,28rem)"
>
<CarouselSlide caption="First slide" captionDescription="A short description over the image.">
<div class="aui-carousel-demo-card">1</div>
</CarouselSlide>
<CarouselSlide caption="Second slide" captionDescription="Captions can carry a muted description too.">
<div class="aui-carousel-demo-card">2</div>
</CarouselSlide>
<CarouselSlide caption="Third slide">
<div class="aui-carousel-demo-card">3</div>
</CarouselSlide>
</Carousel>
<!-- caption + captionDescription render an overlay bar across the
slide's lower edge; a named caption slot takes custom markup -->Autoplay
Carousel autoplays; it pauses on hover and focus.
No buttons, no dots — just the circular pause/play toggle pinned to the top-right corner. Its progress ring fills over the 5s interval (freezes on hover, resets per slide). Still respects reduced-motion and stops permanently if you swipe the deck manually.
<Carousel
label="Autoplaying cards"
autoplay
interval={5000}
autoplayToggle="corner"
controls={false}
showCounter={false}
style="inline-size:min(100%,28rem)"
>
<CarouselSlide><div class="aui-carousel-demo-card">1</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card">2</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card">3</div></CarouselSlide>
<CarouselSlide><div class="aui-carousel-demo-card">4</div></CarouselSlide>
</Carousel>
<!-- autoplay advances every interval ms (implies loop). It pauses on
hover and focus, stops permanently after manual navigation, and
respects prefers-reduced-motion. autoplayToggle="corner" pins a
circular pause/play button to the slides' top-right corner whose
ring fills over the interval — a visible clock to the next slide.
UX guidance: 5 slides or fewer, 5–7s per slide, and don't autoplay
on touch devices -->API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | "Carousel" | Accessible name for the carousel region. |
orientation | "horizontal" | "vertical" | "horizontal" | Scroll axis. |
variant | "single" | "multiple" | "single" | One slide per snap, or a peek layout sized via slideBasis. |
loop | boolean | false | Wrap navigation from the last slide to the first and back. |
defaultIndex | number | 0 | Zero-based slide shown initially. |
controls | boolean | true | Show the prev/next buttons. |
controlsPlacement | "header" | "overlay" | "header" | Buttons in the header row or floating over the slide edges. |
indicators | boolean | false | One dot per slide; click jumps, the active dot tracks the scroll. |
autoplay | boolean | false | Auto-advance on an interval (implies loop); pauses on hover/focus and under reduced motion. |
interval | number | 4000 | Autoplay delay in milliseconds (min 1000). |
showCounter | boolean | horizontal+single | Show the “1 / 5” counter. |
counterLabel | string | "Slide" | Word used in the counter's aria-label. |
previousLabel / nextLabel | string | "Previous slide" / "Next slide" | Accessible labels for the buttons. |
snapType | "mandatory" | "proximity" | "mandatory" | Scroll-snap strictness. |
contentClass | string | — | Classes for the scroll container (e.g. fixed height for vertical). |
slideBasis | string | 100% / 50% | Slide size for variant="multiple" (any flex-basis value). |
gap | string | token | Space between slides (any CSS gap value). |
class | string | — | Merged via cn(); Tailwind classes welcome. |
id | string | — | Element id. |
Accessibility
Implemented accessibility features — semantics, ARIA wiring, and keyboard behavior that ship with the component.
- Root is role=region with aria-roledescription=carousel and a required accessible name
- Slides carry aria-roledescription=slide and “x of y” aria-labels via the script
- Scroll container is tabindex=0 so keyboard users can reach slide navigation
- Buttons carry explicit aria-labels; counter is live text
- Reduced motion: scrollIntoView respects the browser's behavior setting
FAQ
Why scroll-snap instead of transform math?
The browser owns the physics: touch momentum, trackpad inertia, rtl, and resize all behave natively, and the script only syncs state. Transform-based carousels re-implement all of that and usually get drag wrong.
How do I drive it from outside?
Dispatch carousel:set on the root with {index: 2} or {action: 'next'}. Listen for carousel:change to sync external UI like dot indicators.
Changelog
Version history mentioning Carousel.
- General (7): Button, ButtonGroup, Command, Carousel, Clipboard, Kbd, Link.
- Dialog, Lightbox, Sheet, and AlertDialog backdrops, Progress stripes, Carousel shadows, and Chart SVG shadows resolve through tokens instead of hard-coded colors.