Stepper

Shows where the user is in a multi-step flow — checkout, onboarding, a wizard — as an ordered list of steps with the current one marked, and lets them go back to steps they have completed.

Basic usage

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 { Stepper } from '@design-schema/react';

Examples

import { Stepper } from '@design-schema/react';

<Stepper
  steps={[
    { id: 'shipping', label: 'Shipping address' },
    { id: 'payment', label: 'Payment' },
    { id: 'review', label: 'Review order' },
    { id: 'confirm', label: 'Confirmation' },
  ]}
  current="payment"
/>

Props

6 rows
PropTypeDefaultDescriptionPlatforms
labelstring—

Accessible name of the navigation landmark. Defaults to `copy.navLabel`, which an empty string also falls back to (`label || copy.navLabel`), so the landmark is never unnamed.

web, lit, rn, swiftui
steps requiredarray { id: string; label: string; description?: string; status?: "complete" | "current" | "upcoming" | "error" }[]—

The steps in order. `status` is derived from `current` when omitted: before it complete, the step it names current, after it upcoming. An explicit `status` sets only the indicator, its colours and the status word; position (not status) decides the selected state, navigability, the connector colour and the compact reveal.

web, lit, rn, swiftui
current requiredstring—

The id of the current step. The step whose id matches is the selected one (`aria-current="step"` / selected state) and the one compact reveals, whatever its `status`. When no id matches, nothing is selected, every step without an explicit status is upcoming, no step is navigable under `completed` (all still are under `all`), the count reads "Step 1 of m", compact reveals the first step's label (so a compact stepper is never label-less), and development builds log a warning — a developer-only English message, not copy, worded identically on every platform: `Stepper: current "<id>" matches no step id; nothing is selected.` The warning needs a non-empty `current`: an empty one (the Lit property's initial `''`) is treated as not yet set and does not warn.

web, lit, rn, swiftui
orientationhorizontal | verticalhorizontal

Vertical shows descriptions under each label and suits a side column; horizontal does not render descriptions at all (not clipped, and no aria-describedby) and collapses to `compact` below the prose width.

web, lit, rn, swiftui
navigablenone | completed | allcompleted

Which steps can be activated: none (display only), completed steps (the usual — you can go back, not skip ahead), or all (a settings-style flow where order does not matter). "Completed" means visited — any step before the current one by position, including one marked `error`, which is exactly the step a user most needs to return to. The control is the component's own native button, not the Button component, whose single-label API cannot hold an indicator, a label and a description.

web, lit, rn, swiftui
compactbooleanfalse

Show only the current step's label and "Step 2 of 5"; the indicators stay. Horizontal only, set by hand or automatically below the prose width — a vertical stepper has the room, so the prop does nothing there, including the `count`, which a vertical stepper never renders. On web and Lit the labels of the other steps, with their status words, are visually clipped (the visually-hidden technique), not removed, so they stay reachable by a screen reader; on React Native they are not rendered, because each step's accessibilityLabel already carries the label and status. "Step n of m" (`copy.stepOf`) is the `count` part: one muted Text after the list, not in any step and not replacing a description. On web and Lit, where automatic compact is a container query, it is always rendered and `display: none` outside the query (applied through Text's layout-only className); on React Native it is rendered only while compact is in effect. The count follows the list, `stepGap` after it — as the root's own flex/column gap, never a margin, so the gap collapses with the count when it is hidden instead of leaving dead space under every non-compact stepper.

web, lit, rn, swiftui

Events

1 row
EventTypeDescriptionPlatforms
onStepSelectonStepSelect / step-select

Fired when a navigable step is chosen, with its id. The container changes `current`; the stepper never changes it itself.

web, lit, rn, swiftui

Accessibility

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.

Implicit ARIA role: navigation.

Anatomy

liststepindicatorconnectorlabeldescriptioncount

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.

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

Selected state — Selection is exposed programmatically (`aria-selected` / `aria-checked`).

Stepper keyboard support

2 rows
KeyAction
TabMoves between navigable steps in order; non-navigable steps are not focusable.
Enter or SpaceSelects the focused step.

Contrast pairs

7 rows
ForegroundBackgroundLevel
color.foregroundcolor.backgroundAA
color.foreground.mutedcolor.backgroundAA
color.control.selectedForegroundcolor.control.selectedBackgroundAA
color.status.danger.foregroundcolor.status.danger.backgroundAA
color.status.danger.iconcolor.backgroundAA (non-text)
color.control.selectedBackgroundcolor.backgroundAA (non-text)
color.border.strongcolor.backgroundAA (non-text)

This component does not animate, so it has no reduced-motion behaviour to describe.