# Carousel (Moonarc)

A scroll-snap strip with previous/next buttons and dot markers that the browser generates and keeps in sync. Zero JavaScript. Touch, momentum, keyboard and snapping are native everywhere; the buttons and dots appear where ::scroll-button() and ::scroll-marker exist.

- Import: `import Carousel from '@moonarc/core/Carousel'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/carousel.json`
- Tier A · category ui · trigger click, pointer
- Readout: `<Carousel perView={3}>`
- Browser support: limited (Chrome 135 · not Firefox · not Safari); elsewhere: a swipeable, keyboard-scrollable snap strip without buttons or dots (Firefox, Safari)
- Measured cost: 0 B JS (CSS 6.6 kB raw)
- Page: https://moonarc.dev/components/carousel/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `perView` | `number` | `1` | Slides visible at once. |
| `gap` | `string` | `'1rem'` | Gap between slides. |
| `markers` | `boolean` | `true` | Dot markers under the strip (where supported). |
| `buttons` | `boolean` | `true` | Previous / next buttons (where supported). |
| `snap` | `'mandatory' | 'proximity'` | `'mandatory'` | Snap strictness. |
| `label` | `string` | `'Carousel'` | Accessible name of the scrolling region. |

## Usage

```astro
<Carousel perView={3} gap="1rem" label="Testimonials">
  <figure>…</figure>
  <figure>…</figure>
  <figure>…</figure>
</Carousel>
```

## Reduced motion

Buttons and markers jump instead of smooth-scrolling; the strip still snaps.

## With ClientRouter

CSS-only; scroll position resets with the page.

## Craft

- The strip is a grid with auto-flow columns: slide width is one expression of perView and gap, no measuring.
- Buttons are ::scroll-button(): they scroll by a page, disable at the ends, and need no event handler. They are absolutely positioned against the carousel at its inline edges, so they never scroll with the slides, and in a right-to-left page they swap sides and their arrows turn.
- Markers are ::scroll-marker on each slide; :target-current is the browser telling you which slide is in view. Each dot is 8 px inside a 24 px target, the WCAG 2.5.8 minimum.
- scroll-snap-stop: always so a fast flick never skips a slide.

## Replaces

- Embla
- Swiper for simple strips
- Carousel (shadcn)
- Carousel (React Bits)

## Source

```astro
---
/**
 * Carousel — scroll-snap strip with native buttons and markers, zero JS.
 *
 * Children are the slides. Scrolling, snapping, momentum and touch are the
 * browser's. Where ::scroll-button() and ::scroll-marker exist (Chromium),
 * previous/next buttons and dots are generated pseudo-elements that scroll
 * the container and track the current slide; elsewhere the strip is still
 * swipeable and keyboard-scrollable, without the chrome.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Slides visible at once. */
  perView?: number;
  /** Gap between slides, any CSS length. */
  gap?: string;
  /** Dot markers under the strip. */
  markers?: boolean;
  /** Previous / next buttons. */
  buttons?: boolean;
  /** Snap strictness. */
  snap?: 'mandatory' | 'proximity';
  /** Accessible name for the strip. */
  label?: string;
}

const { perView = 1, gap = '1rem', markers = true, buttons = true, snap = 'mandatory', label = 'Carousel', class: className, style, ...rest } = Astro.props;
const vars = [`--ma-car-n:${perView}`, `--ma-car-gap:${gap}`, `--ma-car-snap:${snap}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div class:list={['ma-carousel', className]} data-markers={markers ? '' : undefined} data-buttons={buttons ? '' : undefined} style={vars} {...rest}>
  <div class="ma-carousel__track" role="region" aria-label={label} tabindex="0">
    <slot />
  </div>
</div>

<style is:global>
  @layer components {
    :where(.ma-carousel) {
      position: relative;
    }
    :where(.ma-carousel__track) {
      display: grid;
      grid-auto-flow: column;
      grid-auto-columns: calc((100% - (var(--ma-car-n, 1) - 1) * var(--ma-car-gap, 1rem)) / var(--ma-car-n, 1));
      gap: var(--ma-car-gap, 1rem);
      overflow-x: auto;
      overscroll-behavior-x: contain;
      scroll-snap-type: x var(--ma-car-snap, mandatory);
      scroll-behavior: smooth;
      scrollbar-width: none;
      scroll-marker-group: after;
    }
    :where(.ma-carousel__track)::-webkit-scrollbar {
      display: none;
    }
    :where(.ma-carousel__track:focus-visible) {
      outline: 2px solid currentColor;
      outline-offset: 4px;
      border-radius: 0.5rem;
    }
    :where(.ma-carousel__track > *) {
      scroll-snap-align: start;
      scroll-snap-stop: always;
    }
    /* buttons — generated, and they scroll the container by one page. Plain absolute insets against the
       positioned root: the pseudo-elements sit outside the track's scrollable overflow, so they never scroll
       with the slides, and no anchor is needed (anchoring a pseudo-element to its own container is not
       reliably supported, and the two buttons then land on top of each other). */
    :where(.ma-carousel[data-buttons] .ma-carousel__track)::scroll-button(*) {
      position: absolute;
      top: 50%;
      translate: 0 -50%;
      z-index: 1;
      display: grid;
      place-items: center;
      width: 2.25rem;
      height: 2.25rem;
      border-radius: 999px;
      border: 1px solid var(--ma-edge);
      background: var(--ma-panel);
      color: inherit;
      cursor: pointer;
      font: inherit;
      transition: opacity var(--ma-duration-fast) var(--ma-ease-out), translate var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-carousel[data-buttons] .ma-carousel__track)::scroll-button(*):disabled {
      opacity: 0.3;
      cursor: default;
    }
    :where(.ma-carousel[data-buttons] .ma-carousel__track)::scroll-button(*):hover:not(:disabled) {
      translate: 0 calc(-50% - 1px);
    }
    /* with markers the slides sit above a marker row: centre on the slides, not on the whole box */
    :where(.ma-carousel[data-buttons][data-markers] .ma-carousel__track)::scroll-button(*) {
      top: calc(50% - 0.6875rem);
    }
    :where(.ma-carousel[data-buttons] .ma-carousel__track)::scroll-button(inline-start) {
      content: var(--ma-car-prev, '←') / 'Previous';
      inset-inline-start: 0.5rem;
    }
    :where(.ma-carousel[data-buttons] .ma-carousel__track)::scroll-button(inline-end) {
      content: var(--ma-car-next, '→') / 'Next';
      inset-inline-end: 0.5rem;
    }
    /* right to left the inline-start button is the right-hand one, so the arrows turn with it. [dir] and not :dir():
       :dir() reached Chrome in 120, and a build that targets an older Chrome rewrites it into a guess from lang, which a page that sets only dir never
       matches. An ltr island one level inside an rtl page is set back */
    :where([dir='rtl'] .ma-carousel, .ma-carousel[dir='rtl']) {
      --ma-car-prev: '→';
      --ma-car-next: '←';
    }
    :where([dir='rtl'] [dir='ltr'] .ma-carousel, [dir='rtl'] .ma-carousel[dir='ltr']) {
      --ma-car-prev: '←';
      --ma-car-next: '→';
    }
    /* markers — one per slide, current one filled */
    :where(.ma-carousel[data-markers] .ma-carousel__track)::scroll-marker-group {
      display: flex;
      justify-content: center;
      padding-top: 0.875rem;
    }
    /* a 24 px target (WCAG 2.5.8) around the 8 px dot: the padding is hit area, the colour stays on the content box, the
       negative block margin keeps the row as tall as the dot, and the targets sit edge to edge */
    :where(.ma-carousel[data-markers] .ma-carousel__track > *)::scroll-marker {
      content: '';
      width: 0.5rem;
      height: 0.5rem;
      padding: max(0.5rem, calc(12px - 0.25rem));
      margin-block: calc(-1 * max(0.5rem, calc(12px - 0.25rem)));
      border-radius: 999px;
      background: var(--ma-edge);
      background-clip: content-box;
      transition: background-color var(--ma-duration-fast) var(--ma-ease-out), scale var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-carousel[data-markers] .ma-carousel__track > *)::scroll-marker:target-current {
      background-color: currentColor;
      scale: 1.25;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-carousel__track) {
        scroll-behavior: auto;
      }
    }
  }
</style>

```
