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, shown without its comments; the files you install keep them, with the reasoning behind every rule.
---
import "../../../../styles/structure.css";
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
label?: string;
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 />
<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";
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());
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);
});
}
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();
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)));
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)));
let queued = false;
viewport.addEventListener("scroll", () => {
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
sync();
});
});
new ResizeObserver(sync).observe(viewport);
sync();
}
onReadyOnce('[data-slot="carousel"]', wire);
</script>
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
label?: string;
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>
---
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>
---
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>
---
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>
---
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>
export function currentIndex(
scrollStart: number,
starts: readonly number[],
maxScroll = Infinity,
): number {
if (starts.length === 0) return -1;
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;
}
export function stepIndex(scrollStart: number, starts: readonly number[], step: 1 | -1): number {
if (starts.length === 0) return -1;
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.