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.