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; there is nothing else.

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