Product 1
Product 1
A set of slides shown one (or a few) at a time with previous/next controls, a slide picker, and optional auto-rotation that pauses the moment anyone touches it — the APG carousel with the accessibility built in rather than bolted on.
Install the package:
pnpm add @design-schema/react @design-schema/tokens Import the stylesheet once for the app, then the component:
import '@design-schema/tokens/calm-precise/css';
import '@design-schema/react/index.css';
import { Carousel } from '@design-schema/react'; import type { ReactElement } from 'react';
import { Carousel, CarouselSlide, Card } from '@design-schema/react';
function slides(names: string[]): ReactElement[] {
return names.map((name) => (
<CarouselSlide key={name} label={name}>
<Card heading={name}>{name}</Card>
</CarouselSlide>
));
}
<Carousel label="Featured products">{slides(['Product 1', 'Product 2', 'Product 3', 'Product 4'])}</Carousel>| Prop | Type | Default | Description |
|---|---|---|---|
| label required | string | — | What the carousel shows ("Featured products", "Customer stories"). Accessibility: aria-label on the region; each slide is named `copy.slideLabel` ("2 of 4") alone — its heading is read as content, never joined into the name. |
| children required | content | — | One `CarouselSlide` per slide. A slide is any content; a Card is the usual shape. Slides should be equal height. |
| perView | number | 1 | How many slides are visible at once at the widest layout. The page size is `perView` while the viewport's own width is above `layout.maxWidth.prose` and 1 at or below it, measured on the viewport (ResizeObserver on web and Lit, onLayout on React Native); `perView` applies until the first measurement. The comparison needs the token as a number: web and Lit read it from its built custom property on the viewport (px and rem both parsed), React Native from the theme; when it cannot be read, `perView` stands as given. |
| loop | boolean | false | Next from the last returns to the first. Off by default so users can tell where the end is. |
| autoplay | boolean | false | Rotate automatically every `interval`. Starts only when the user has not asked for reduced motion; stops on hover, focus, touch, or the play/pause button; never restarts on its own after the user pauses it. |
| interval | number | 6000 | Milliseconds between automatic advances; values below 5000 are raised to 5000 in every build on every platform, with a development warning once ever per instance — a carousel that is given a valid `interval` and then an invalid one again does not warn a second time — and only while `autoplay` is on. |
| picker | dots | tabs | none | dots | How slides are chosen directly: small dot buttons, tabs with each slide's label (for few, meaningful slides), or none (arrows only). |
| activeIndex | number | — | Controlled current slide (zero-based). Omit for uncontrolled. |
| snap | boolean | true | Swiping or scrolling snaps to slide boundaries. |
| Event | Type | Description |
|---|---|---|
| onChange | onChange / change | Fired when the current slide changes, with the new index and the reason (`next`, `prev`, `picker`, `swipe`, `autoplay`). The programmatic cases are autoplay and a swipe settling; a change of a controlled `activeIndex` never fires it. A scroll counts as `swipe` only when the user started it (web and Lit: after pointerdown, touchstart or wheel on the viewport; React Native: between onScrollBeginDrag and onMomentumScrollEnd, and also at onScrollEndDrag, since momentum is only reported after the drag has ended — the change settles there and the swipe window reopens in onMomentumScrollBegin when a drag had just ended, so a flick that carries further reports again where it lands, and a controlled carousel simply scrolls back twice to the same offset; a programmatic animated scroll clears the drag flag first, so its own momentum events are never read as a swipe) — a scroll started by an arrow, the picker or autoplay reports its own reason once. A swipe settles on the viewport's `scrollend` event where the engine has it and on the next IntersectionObserver delivery otherwise, never on a timer. |
Decisions this component leaves to you
Supply an accessible name. This component has no text of its own to fall back on, so without one a screen reader announces its role and nothing else.
If you wire up the gesture events, provide the pointer and keyboard path to the same outcome.
Required props that carry accessibility meaning: label. Each is described in the props table above.
Implicit ARIA role: region.
Anatomy
What generation guarantees
Accessible name — Every instance carries a programmatic name. Where the component has no text of its own to use, the name is a prop the consumer supplies.
Keyboard operable — Everything the pointer can do here, the keyboard can do — see the keyboard section.
Arrow navigation — Arrow keys move between items; Home and End jump to the ends.
Visible focus — Keyboard focus is always drawn, from the theme’s focus tokens, and is never removed.
Contrast — Text and meaningful non-text contrast meets WCAG AA; the pairs are listed below.
Target size — Interactive targets are at least 24×24 CSS pixels (WCAG 2.2 Target Size, minimum).
Reduced motion — Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.
Live region — Changes are announced through a live region, without moving focus.
Gesture alternative — Every gesture — swipe, drag, long-press — has a single-pointer and a keyboard equivalent.
No hover-only content — Nothing is reachable by hover alone; keyboard focus reveals the same content.
Selected state — Selection is exposed programmatically (`aria-selected` / `aria-checked`).
Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.