Skip to main content
astrocraft-ui/ components · 101

Carousel

Free · MIT

Headless, 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

Install command
npx astrocraft-ui add media/carousel

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add media/carousel --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add media/carousel --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add media/carousel --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add media/carousel --theme tailwind --bridge lumos

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

ComponentSlotsVariantsRuntime stateNative 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.

src/components/ui/media/carousel/Carousel.astro
---
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>
src/components/ui/media/carousel/CarouselIndicators.astro
---
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>
src/components/ui/media/carousel/CarouselNext.astro
---
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
---
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
---
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
---
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
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;
}
src/components/ui/media/carousel/index.ts
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.