Skip to content

Components / UI

Carousel

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.

Live demo

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 6.6 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Limited availability

Chrome 135 · not Firefox · not Safari; needs scroll-snap, scroll-marker. Elsewhere: a swipeable, keyboard-scrollable snap strip without buttons or dots (Firefox, Safari).

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Carousel from '@moonarc/core/Carousel';
---
<Carousel perView={3} gap="1rem" label="Testimonials">
  <figure>…</figure>
  <figure>…</figure>
  <figure>…</figure>
</Carousel>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/carousel.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

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

Props

PropTypeDefaultDescription
perViewnumber1Slides visible at once.
gapstring'1rem'Gap between slides.
markersbooleantrueDot markers under the strip (where supported).
buttonsbooleantruePrevious / next buttons (where supported).
snap'mandatory' | 'proximity''mandatory'Snap strictness.
labelstring'Carousel'Accessible name of the scrolling region.

Reduced motion

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

With ClientRouter

CSS-only; scroll position resets with the page.

Why it is built this way

Replaces: Embla · Swiper for simple strips · Carousel (shadcn) · Carousel (React Bits). See the migration table.

Cards stick to the top as you scroll and each one shrinks and dims as the next slides over it, as on Apple product pages.

Chrome 116 · not Firefox · Safari 26scroll

Marquee

Ticker

Infinite scrolling strip for logos, quotes or tags in any direction.

every browseralwayshover
npx astro add moonarc
pnpm astro add moonarc
npx shadcn@latest add …/r/tabs.json

Tabs with an indicator that slides to the selected one and panels that fade in.

every browserclick

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown