Carousel
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add media/carouselPlain-CSS theme — no build step
npx astrocraft-ui add media/carousel --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add media/carousel --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add media/carousel --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add media/carousel --theme tailwind --bridge lumosLive demo
Carousel
overflow-x: auto and scroll-snap-type ARE the carousel — drag it, flick it, or tab to the strip and use the arrow keys, all before a line of JavaScript runs. The script adds the three things CSS cannot answer: which slide is showing, whether an arrow has run out of track, and saying so in a live region.
Scroll-snap
The browser does the motion. No easing curve ships here.
Arrow keys
The viewport is a tab stop, so it scrolls without the buttons.
Dots
Cloned from a <template>; only the script can count the slides.
aria-live
Announces the position, because scrolling is silent.
No wrap
The arrows disable at the ends rather than jumping the track.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `Carousel.astro` | `carousel` `carousel-status` | — | — | — |
| `CarouselIndicators.astro` | `carousel-indicator` `carousel-indicator-template` `carousel-indicators` | — | — | `[aria-current]` |
| `CarouselNext.astro` | `carousel-next` | — | — | — |
| `CarouselPrev.astro` | `carousel-prev` | — | — | — |
| `CarouselSlide.astro` | `carousel-slide` | — | — | — |
| `CarouselViewport.astro` | `carousel-viewport` | — | — | — |
Source
What the command copies — 8 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/media/carousel/Carousel.astro — headless primitive (see ../../README.md).
// A scroll-snap carousel. CSS does the carousel — `overflow-x: auto` plus `scroll-snap-type` on the
// viewport, `scroll-snap-align` on the slides (structure.css, because without them there is nothing
// to scroll and every control here is inert). The script owns only the three things CSS cannot
// answer: which slide is showing, whether an arrow has run out of track, and saying so out loud.
//
// It therefore degrades honestly. With no JavaScript at all the viewport is still a scroll container
// with `tabindex="0"`, so it snaps under a finger, a trackpad and the arrow keys; the buttons are
// simply inert. That is the reason the slides are not `hidden` and there is no "active" slide state:
// the browser's scroll position IS the state, and nothing here can disagree with it.
//
// Compose CarouselViewport (with CarouselSlides inside), then CarouselPrev / CarouselNext /
// CarouselIndicators in any order.
//
// ponytail: horizontal, left-to-right. A vertical carousel means swapping every `scrollLeft` for
// `scrollTop` and `clientWidth` for `clientHeight`; RTL additionally means the sign of `scrollLeft`
// varies by engine. Neither is free, and neither has asked to exist yet.
import "../../../../styles/structure.css";
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
/** Names the carousel. `aria-roledescription` is only announced on a named region. */
label?: string;
/** Announced on each slide change, before the position — "Slide 3 of 8". */
slideWord?: string;
};
const { label = "Carousel", slideWord = "Slide", class: className, ...rest } = Astro.props;
---
<div
role="region"
aria-roledescription="carousel"
aria-label={label}
data-slot="carousel"
data-slide-word={slideWord}
class={className}
{...rest}
>
<slot />
{/* Empty until the script knows a position — before it runs there is no position to report. */}
<p aria-live="polite" data-slot="carousel-status"></p>
</div>
<script>
import { onReadyOnce } from "../../_once";
import { currentIndex, stepIndex } from "./carousel-scroll";
function wire(root: HTMLElement) {
const viewport = root.querySelector<HTMLElement>('[data-slot="carousel-viewport"]');
if (!viewport) return;
const slides = [...viewport.querySelectorAll<HTMLElement>('[data-slot="carousel-slide"]')];
if (slides.length === 0) return;
const status = root.querySelector<HTMLElement>('[data-slot="carousel-status"]');
const prev = root.querySelector<HTMLButtonElement>('[data-slot="carousel-prev"]');
const next = root.querySelector<HTMLButtonElement>('[data-slot="carousel-next"]');
const dotsHost = root.querySelector<HTMLElement>('[data-slot="carousel-indicators"]');
const slideWord = root.dataset.slideWord || "Slide";
// Positions are measured on every use rather than cached: slides are authored content, so a font
// that finishes loading, an image that arrives, or a container query resizing the viewport all
// move them — and a cached offset would send "next" to the wrong place, silently.
//
// SCROLL-PADDING IS PART OF THE MEASUREMENT, not a detail. `scroll-snap-align: start` aligns a
// slide with the SNAPPORT — the scroll port inset by `scroll-padding` — so a theme that pads its
// carousel (this library's own reference theme does, to keep a snapped slide off the edge) moves
// every snap position by that much. Measuring raw offsets aims each scroll a padding's width
// past where the browser will actually stop, the snap pulls it back to where it started, and
// "next" moves nothing at all. Reading the physical property is deliberate: a logical
// `scroll-padding-inline-start` resolves into it, so this covers both spellings.
const starts = () => {
const pad = Number.parseFloat(getComputedStyle(viewport).scrollPaddingLeft) || 0;
const origin = viewport.getBoundingClientRect().left - viewport.scrollLeft + pad;
return slides.map((slide) => slide.getBoundingClientRect().left - origin);
};
const maxScroll = () => viewport.scrollWidth - viewport.clientWidth;
const at = () => currentIndex(viewport.scrollLeft, starts(), maxScroll());
// Reduced motion is honoured here rather than left to `scroll-behavior`, because this scroll is
// the library's and a consumer cannot reach it. "instant" and not "auto": `auto` inherits a
// consumer's own `scroll-behavior: smooth` and lands us back on the animated path we just opted
// out of.
const go = (index: number) => {
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
viewport.scrollTo({ left: starts()[index], behavior: reduced ? "instant" : "smooth" });
};
const dots: HTMLButtonElement[] = [];
const template = dotsHost?.querySelector<HTMLTemplateElement>(
'[data-slot="carousel-indicator-template"]',
);
if (dotsHost && template) {
const label = dotsHost.dataset.goToLabel || "Go to slide";
slides.forEach((_, i) => {
const dot = template.content.firstElementChild?.cloneNode(true);
if (!(dot instanceof HTMLButtonElement)) return;
dot.setAttribute("aria-label", `${label} ${i + 1}`);
dot.addEventListener("click", () => go(i));
dotsHost.append(dot);
dots.push(dot);
});
}
// Each slide is told where it sits — an author-written `aria-label` wins, because "Autumn
// collection" is a better answer than "3 of 8" when someone bothered to write one.
slides.forEach((slide, i) => {
if (!slide.hasAttribute("aria-label") && !slide.hasAttribute("aria-labelledby")) {
slide.setAttribute("aria-label", `${i + 1} of ${slides.length}`);
}
});
let announced = -1;
const sync = () => {
const index = at();
// Disabled by the TRACK, not by the index. At the end of a carousel whose slides are narrower
// than the viewport the reported index stops changing while there is still scrolling to do,
// so an index test disables "next" a slide early — and the one-pixel tolerance is again the
// fractional device pixel ratio that never quite reaches the maximum.
if (prev) prev.disabled = viewport.scrollLeft <= 1;
if (next) next.disabled = viewport.scrollLeft >= maxScroll() - 1;
dots.forEach((dot, i) => dot.setAttribute("aria-current", String(i === index)));
// Only on a real change: `scroll` fires every frame of a drag, and a live region rewritten
// every frame is a screen reader that says nothing useful very loudly. The FIRST pass records
// the position without speaking it — writing into a live region during page load makes every
// carousel on the page announce itself unprompted, which is not a change and not news.
if (status && index !== announced) {
if (announced !== -1) status.textContent = `${slideWord} ${index + 1} of ${slides.length}`;
announced = index;
}
};
prev?.addEventListener("click", () => go(stepIndex(viewport.scrollLeft, starts(), -1)));
next?.addEventListener("click", () => go(stepIndex(viewport.scrollLeft, starts(), 1)));
// rAF-coalesced: `scroll` can fire many times per frame, and `sync` reads layout.
let queued = false;
viewport.addEventListener("scroll", () => {
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
sync();
});
});
// A resize moves every slide start, so the current index can change without a scroll event.
new ResizeObserver(sync).observe(viewport);
sync();
}
onReadyOnce('[data-slot="carousel"]', wire);
</script>
---
// src/components/ui/media/carousel/CarouselIndicators.astro — Carousel compound part (see ../../README.md).
// The dots. It renders a <template> and no dots: only the script can count the slides, and cloning
// the markup from a template keeps it here in the .astro file rather than built as a string in
// JavaScript — the same arrangement TagsInput and FileUpload use for their repeated rows.
//
// Buttons with `aria-current`, deliberately NOT `role="tablist"`. Tabs promise panels that are shown
// one at a time and hidden the rest; every slide in this carousel is present and scrolled to. Saying
// "tab" would describe a component this is not.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
label?: string;
/** Prefix for each dot's name — rendered as "Go to slide 3". */
goToLabel?: string;
};
const { label = "Slides", goToLabel = "Go to slide", class: className, ...rest } = Astro.props;
---
<div
role="group"
aria-label={label}
data-slot="carousel-indicators"
data-go-to-label={goToLabel}
class={className}
{...rest}
>
<template data-slot="carousel-indicator-template">
<button type="button" data-slot="carousel-indicator" aria-current="false"></button>
</template>
<slot />
</div>
---
// src/components/ui/media/carousel/CarouselNext.astro — Carousel compound part (see ../../README.md).
// Scrolls on one slide; disabled at the end of the track. See CarouselPrev.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"button"> & { label?: string };
const { label = "Next slide", class: className, ...rest } = Astro.props;
---
<button type="button" aria-label={label} data-slot="carousel-next" class={className} {...rest}>
<slot />
</button>
---
// src/components/ui/media/carousel/CarouselPrev.astro — Carousel compound part (see ../../README.md).
// Scrolls back one slide. The Carousel script disables it at the start of the track with the native
// `disabled` attribute — real disablement, so the browser takes it out of the tab order and
// announces it, rather than a class that only looks spent.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"button"> & { label?: string };
const { label = "Previous slide", class: className, ...rest } = Astro.props;
---
<button type="button" aria-label={label} data-slot="carousel-prev" class={className} {...rest}>
<slot />
</button>
---
// src/components/ui/media/carousel/CarouselSlide.astro — Carousel compound part (see ../../README.md).
// One slide. `role="group"` + `aria-roledescription="slide"` is what makes a screen reader say
// "slide 3 of 8, group" instead of reading eight anonymous divs in a row; the position half of that
// label is filled in by the Carousel script when no `label` is given, since only it can count.
//
// A slide is never hidden. All of them are in the DOM and in the scroll container at once — that is
// what makes the carousel work without JavaScript, and it is why there is no "active" state here to
// keep in sync with the scroll position.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & { label?: string };
const { label, class: className, ...rest } = Astro.props;
---
<div
role="group"
aria-roledescription="slide"
aria-label={label}
data-slot="carousel-slide"
class={className}
{...rest}
>
<slot />
</div>
---
// src/components/ui/media/carousel/CarouselViewport.astro — Carousel compound part (see ../../README.md).
// The scroll container, and the whole carousel mechanism: structure.css gives it `overflow-x: auto`
// and `scroll-snap-type`, so it snaps with a finger, a trackpad, a mouse wheel and the arrow keys
// before a line of this library's JavaScript has run.
//
// `tabindex="0"` is the accessibility half and the one hand-rolled carousels miss. A scrollable box
// is only keyboard-operable if it can take focus, and browsers only grant that automatically to
// scrollers with NO focusable content — which a carousel of cards with links in them is not. Without
// it, a keyboard user can reach every link inside and still not be able to scroll the thing.
// The rule guards against tab stops on inert content, and a scroll container is not inert: it is
// the one part of this component a keyboard has to reach. Deleting the tabindex deletes the
// carousel's keyboard access (WCAG 2.1.1) and leaves no error behind.
/* eslint-disable astro/jsx-a11y/no-noninteractive-tabindex */
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & { label?: string };
const { label = "Slides", class: className, ...rest } = Astro.props;
---
<div
tabindex="0"
role="group"
aria-label={label}
data-slot="carousel-viewport"
class={className}
{...rest}
>
<slot />
</div>
// src/components/ui/media/carousel/carousel-scroll.ts — the arithmetic a scroll-snap carousel needs and
// the platform does not provide: WHICH slide is showing, and where the next one starts. Pure, so it
// is checkable without a DOM (see carousel-scroll.test.ts).
//
// CSS does all the motion here — `overflow-x: auto` plus `scroll-snap-type` is the carousel. What is
// left over is bookkeeping: the arrows need to know whether they are at an end, the dots need to
// know which one to mark, and the live region needs to know when the answer changed.
/**
* Index of the slide currently at the viewport's leading edge — the nearest slide start to where the
* container is scrolled.
*
* `maxScroll` is not optional padding: the last slides of a carousel whose slides are narrower than
* the viewport can NEVER reach the leading edge, because the container runs out of scroll first.
* Without the end clamp the final dot is unreachable and the "next" arrow never disables.
*
* @param scrollStart - the container's current scroll offset along the scroll axis
* @param starts - each slide's offset from the container's scroll origin, in document order
* @param maxScroll - `scrollWidth - clientWidth`; omit when it does not apply
* @returns the index into `starts`, or `-1` when there are no slides
* @example currentIndex(210, [0, 200, 400]) // => 1
*/
export function currentIndex(
scrollStart: number,
starts: readonly number[],
maxScroll = Infinity,
): number {
if (starts.length === 0) return -1;
// A sub-pixel tolerance, not a fudge: fractional device pixel ratios and browser zoom leave
// `scrollLeft` a fraction short of `scrollWidth - clientWidth` at the very end of the track, so an
// exact comparison reports "not at the end" on exactly the hardware that is hardest to notice on.
if (scrollStart >= maxScroll - 1) return starts.length - 1;
let best = 0;
for (let i = 1; i < starts.length; i += 1) {
if (Math.abs(starts[i] - scrollStart) < Math.abs(starts[best] - scrollStart)) best = i;
}
return best;
}
/**
* The index the arrows scroll to: one snap position along the track from where the container is now.
*
* Deliberately positional rather than `currentIndex() + step`, and that is not a refinement — index
* arithmetic is BROKEN at the end of a carousel whose slides are narrower than the viewport. There,
* several slide starts sit past the maximum scroll, so the reported index (correctly, thanks to the
* end clamp above) is the last slide while the scroll position is still back at slide three's start.
* Stepping back from the index targets a start the container can never reach, the browser clamps it
* to where it already was, and "previous" silently does nothing at the one end of the track a user
* is most likely to press it from.
*
* Reading the track instead — the next start strictly beyond the current position — cannot have that
* problem, because it is asking the question the scroll container actually answers.
*
* Ends are clamped, not wrapped: a scroll-snap carousel that wraps has to jump the length of the
* whole track, and a jump is not what a "next" button was pressed for. (That is the opposite of
* Lightbox's `nextIndex`, where a wrap costs nothing because there is no track to travel.)
*
* @param scrollStart - the container's current scroll offset
* @param starts - each slide's offset, ascending
* @param step - `1` for the next slide, `-1` for the previous
* @returns the index to scroll to, or `-1` when there are no slides
* @example stepIndex(281, [0, 281, 549], -1) // => 0
*/
export function stepIndex(scrollStart: number, starts: readonly number[], step: 1 | -1): number {
if (starts.length === 0) return -1;
// The same sub-pixel tolerance as above, for the same reason: a position a fraction off a slide's
// own start must not count as "beyond" it, or one press moves nothing.
if (step > 0) {
const ahead = starts.findIndex((start) => start > scrollStart + 1);
return ahead === -1 ? starts.length - 1 : ahead;
}
for (let i = starts.length - 1; i >= 0; i -= 1) {
if (starts[i] < scrollStart - 1) return i;
}
return 0;
}
import Carousel from "./Carousel.astro";
import CarouselIndicators from "./CarouselIndicators.astro";
import CarouselNext from "./CarouselNext.astro";
import CarouselPrev from "./CarouselPrev.astro";
import CarouselSlide from "./CarouselSlide.astro";
import CarouselViewport from "./CarouselViewport.astro";
export {
Carousel,
CarouselIndicators,
CarouselNext,
CarouselPrev,
CarouselSlide,
CarouselViewport,
};
export default Carousel;
What you get
The component source, copied into your project by npx astrocraft-ui add media/carousel — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.