Carousel

Three slides

Five slides


  
/*
 * Carousel — block
 *
 * Minimal scope per the answered design question: scroll-snap track,
 * prev/next buttons, pagination dots. No autoplay, no loop, no fade
 * in v1.0.
 *
 *   <section class="carousel">
 *     <div class="carousel__viewport">
 *       <div class="carousel__track">
 *         <div class="carousel__slide">…</div>
 *         <div class="carousel__slide">…</div>
 *         <div class="carousel__slide">…</div>
 *       </div>
 *     </div>
 *     <div class="carousel__controls">
 *       <button class="carousel__prev" aria-label="Previous">‹</button>
 *       <ol class="carousel__dots" role="tablist" aria-label="Slide">
 *         <li><button class="carousel__dot" aria-selected="true" aria-label="Slide 1"></button></li>
 *         <li><button class="carousel__dot" aria-label="Slide 2"></button></li>
 *         <li><button class="carousel__dot" aria-label="Slide 3"></button></li>
 *       </ol>
 *       <button class="carousel__next" aria-label="Next">›</button>
 *     </div>
 *   </section>
 */

@layer malevich.components {
  .carousel {
    display: flex;
    flex-direction: column;
    gap: var(--space-gap-elements-m);

    inline-size: 100%;
    box-sizing: border-box;
  }

  .carousel__viewport {
    overflow: hidden;
    inline-size: 100%;
  }

  .carousel__track {
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: minmax(var(--carousel-slide-min), 1fr);
    gap: var(--space-gap-elements-m);

    overflow-x: auto;
    scroll-snap-type: x mandatory;
    scroll-behavior: smooth;

    /* Hide scrollbar — carousel uses buttons / dots / native swipe.
     * Authors who want a visible scrollbar override locally.
     */
    scrollbar-width: none;
  }
  .carousel__track::-webkit-scrollbar {
    display: none;
  }

  .carousel__slide {
    scroll-snap-align: start;
    scroll-snap-stop: always;
    min-inline-size: 0;
  }

  /* Controls row */

  .carousel__controls {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-gap-elements-s);
  }

  .carousel__prev,
  .carousel__next {
    appearance: none;
    -webkit-appearance: none;
    margin: 0;
    padding: 0;
    background: var(--color-surface-raised);
    color: var(--color-ink-strong);
    border: var(--border-width-hairline) solid var(--color-border-default);
    border-radius: 50%;
    inline-size: var(--size-control-m);
    block-size: var(--size-control-m);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    flex-shrink: 0;

    transition: background-color var(--motion-fast) var(--motion-easing-default);
  }

  .carousel__prev:hover:not(:disabled),
  .carousel__next:hover:not(:disabled) {
    background-color: var(--color-surface-canvas);
  }

  .carousel__prev:focus-visible,
  .carousel__next:focus-visible {
    outline: var(--border-width-focus) solid var(--color-accent);
    outline-offset: var(--border-width-hairline);
  }

  .carousel__prev:disabled,
  .carousel__next:disabled {
    cursor: not-allowed;
    opacity: 0.5;
  }

  .carousel__dots {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    align-items: center;
    gap: var(--space-gap-elements-s);
  }

  .carousel__dot {
    appearance: none;
    -webkit-appearance: none;
    margin: 0;
    padding: 0;
    background: var(--color-border-strong);
    border: 0;
    cursor: pointer;
    border-radius: 50%;
    inline-size: var(--space-inset-element-s);
    block-size: var(--space-inset-element-s);

    transition: background-color var(--motion-fast) var(--motion-easing-default);
  }

  .carousel__dot[aria-selected="true"] {
    background-color: var(--color-accent);
  }

  .carousel__dot:focus-visible {
    outline: var(--border-width-focus) solid var(--color-accent);
    outline-offset: var(--border-width-hairline);
  }

  @media (prefers-reduced-motion: reduce) {
    .carousel__track {
      scroll-behavior: auto;
    }
  }
}
// Carousel — runtime.
//
// Wires prev/next buttons and pagination dots to the scroll-snap
// track. The scroll position is the source of truth; the runtime
// updates dot `aria-selected` and button `disabled` state in response
// to scroll.

const READY_ATTR = "data-malevich-ready";

function getActiveIndex(track: HTMLElement): number {
  const slides = Array.from(track.querySelectorAll<HTMLElement>(".carousel__slide"));
  if (slides.length === 0) return 0;
  const trackRect = track.getBoundingClientRect();
  let best = 0;
  let bestDistance = Infinity;
  for (let i = 0; i < slides.length; i++) {
    const slideRect = slides[i].getBoundingClientRect();
    const distance = Math.abs(slideRect.left - trackRect.left);
    if (distance < bestDistance) {
      bestDistance = distance;
      best = i;
    }
  }
  return best;
}

function scrollToSlide(track: HTMLElement, index: number): void {
  const slides = track.querySelectorAll<HTMLElement>(".carousel__slide");
  const slide = slides[index];
  if (!slide) return;
  const trackRect = track.getBoundingClientRect();
  const slideRect = slide.getBoundingClientRect();
  track.scrollBy({
    left: slideRect.left - trackRect.left,
    behavior: "smooth",
  });
}

export function initCarousel(host: HTMLElement): void {
  if (host.getAttribute(READY_ATTR) === "true") return;

  const track = host.querySelector<HTMLElement>(".carousel__track");
  if (!track) return;

  const prev = host.querySelector<HTMLButtonElement>(".carousel__prev");
  const next = host.querySelector<HTMLButtonElement>(".carousel__next");
  const dots = Array.from(
    host.querySelectorAll<HTMLButtonElement>(".carousel__dot"),
  );

  function sync(): void {
    const i = getActiveIndex(track!);
    const total = track!.querySelectorAll(".carousel__slide").length;

    dots.forEach((dot, idx) => {
      if (idx === i) dot.setAttribute("aria-selected", "true");
      else dot.setAttribute("aria-selected", "false");
    });

    if (prev) prev.disabled = i === 0;
    if (next) next.disabled = i >= total - 1;
  }

  prev?.addEventListener("click", () => {
    scrollToSlide(track, Math.max(0, getActiveIndex(track) - 1));
  });
  next?.addEventListener("click", () => {
    scrollToSlide(track, getActiveIndex(track) + 1);
  });

  dots.forEach((dot, idx) => {
    dot.addEventListener("click", () => scrollToSlide(track, idx));
  });

  track.addEventListener("scroll", () => {
    window.requestAnimationFrame(sync);
  });

  sync();
  host.setAttribute(READY_ATTR, "true");
}

export function initCarousels(root: ParentNode = document): void {
  for (const host of root.querySelectorAll<HTMLElement>(".carousel")) {
    initCarousel(host);
  }
}
# Carousel — AGENTS.md

> Auto-generated from `carousel.docs.md` + the modifier applicability matrix.
> Edit those source files; this file regenerates on build.

## Identity

- **Component:** `carousel`
- **Layer:** blocks
- **Status:** stable
- **Last updated:** 2026-05-19

## Summary

- Product galleries, testimonial slides, feature highlights.

## Modifier applicability

Per ADR-0007. Modifiers attach via `data-{category}="value"` attributes.

| Category | Accepts |
|---|---|
| `background` | — |
| `surface` | — |
| `effect` | — |
| `shader` | — |
| `rhythm` | `compact`, `regular` |
| `motion` | any value |
| `behavior` | — |

## Anatomy

```html
<section class="carousel" aria-roledescription="carousel">
  <div class="carousel__viewport">
    <div class="carousel__track" tabindex="0" aria-label="Slides">
      <article class="carousel__slide" aria-roledescription="slide" aria-label="1 of 3">…</article>
      <article class="carousel__slide" aria-roledescription="slide" aria-label="2 of 3">…</article>
      <article class="carousel__slide" aria-roledescription="slide" aria-label="3 of 3">…</article>
    </div>
  </div>

  <div class="carousel__controls">
    <button type="button" class="carousel__prev" aria-label="Previous slide">‹</button>
    <ol class="carousel__dots" role="tablist" aria-label="Slide navigation">
      <li><button type="button" class="carousel__dot" aria-selected="true"  aria-label="Go to slide 1"></button></li>
      <li><button type="button" class="carousel__dot" aria-selected="false" aria-label="Go to slide 2"></button></li>
      <li><button type="button" class="carousel__dot" aria-selected="false" aria-label="Go to slide 3"></button></li>
    </ol>
    <button type="button" class="carousel__next" aria-label="Next slide">›</button>
  </div>
</section>
```

The runtime (`initCarousel`, auto-registered for `.carousel`) syncs
`aria-selected` on dots and `disabled` on prev/next buttons in
response to scroll position. Clicking prev/next/dot triggers a
smooth scroll on the track.

The native scroll-snap and overflow-x give touch swipe, mouse drag
(on supporting browsers), and keyboard scroll for free when the track
has `tabindex="0"`.

## Tokens consumed

### From semantic tier
- `--color-surface-raised`, `--color-surface-canvas` — button surface
- `--color-ink-strong` — button text
- `--color-border-default`, `--color-border-strong` — button border + dot
- `--color-accent` — focus ring, active dot
- `--space-gap-elements-{s,m}` — gaps
- `--space-inset-element-s` — dot size
- `--size-control-m` — prev/next button size
- `--border-width-hairline` / `--border-width-focus`
- `--motion-fast`, `--motion-easing-default`

### Component-tier (generated)
- `--carousel-slide-min` — minimum slide width (default `18rem`)

## Accessibility contract

- The outer `<section>` carries `aria-roledescription="carousel"`.
- Each slide carries `aria-roledescription="slide"` and an
  `aria-label` describing position.
- The dot row is a `role="tablist"` with `aria-label`. Active dot has
  `aria-selected="true"`.
- Prev/next buttons have `aria-label="Previous slide"` /
  `"Next slide"` and become `disabled` at the ends.
- The track has `tabindex="0"` so keyboard users can scroll it with
  arrow keys (native overflow behavior).

For full keyboard navigation (Left/Right arrows step slides
programmatically), pair with a small inline script — the runtime
does not bind keyboard events itself (browsers already handle
arrow-key scrolling on the focused track).

## Guidance

### Do

- Use the full structure: viewport + track + controls.
- Provide `aria-label` on each slide describing what it shows.
- Keep slide count small (3-7). Larger sets become tedious to navigate.

### Don't

- Don't autoplay in v1.0 (not implemented; deferred to v1.1 per the
  answered design question).
- Don't override `overflow-x` on the track — scroll-snap depends on
  it.
- Don't put deeply interactive content in slides if the slides
  themselves are dragged horizontally — touch users can't reliably
  scroll past it.