Overview
<Stepper /> is the multi-step progress indicator for wizards, onboarding, checkout, KYC,
and any flow where the user advances through a known list of stages.
It ships in two flavors that resolve to the same <ol> shape:
- Array API — pass
steps={[{ id, label, description }, …]}+active={n}. Best for data-driven wizards where every step is uniform. - Compound API —
<Stepper.Step id label>children inside<Stepper>. Best when one step needs inline expanded content (a form, an error message, a sub-task list).
Status auto-derives from active: indexes < active paint as complete, === active
paints as active, > active stays pending. Override per step with status: 'error' or
status: 'loading' (the latter auto-injects <Spinner />).
Overview — four-step horizontal stepper
Anatomy
| Subpart | Element | Role |
|---|---|---|
<Stepper> | <ol role="list" aria-label="Progress"> | container + ordered list of steps |
<Stepper.Step> | <li data-stepper-item> | a single step; carries aria-current="step" when active |
| internal indicator | <span> (or <button> if clickable) | renders the glyph: number / dot / check / alert / Spinner |
| internal connector | <li role="presentation" aria-hidden> | visual line between indicators; status mirrors the preceding step |
Examples
Basic
Vertical
Variants
Sizes
WithDescriptions
WithErrorStep
WithLoadingStep
Clickable
ClickableCompleted
LinearMode
Responsive
Compound
VerticalWithContent
CustomIcons
CustomConnector
Wizard
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| active | number | — | Index of the currently active step (0-based). Drives default status auto-derivation. |
| align | enum | 'start' | Where labels sit relative to the indicator in horizontal mode. |
| aria-label | string | — | Accessible label for the root `<ol>`. Defaults to "Progress". When `<I18nProvider>` ships this will fall back to `t('stepper.label')`; until then consumers can override via prop. |
| children | ReactNode | — | Compound API children — `<Stepper.Step>` elements. Ignored when `steps` is provided. |
| clickable | StepperClickable | false | Whether steps are interactive. |
| completedIcon | ReactNode | — | Override the default check icon for completed steps. |
| connector | ReactNode | — | Custom connector node (e.g. a dashed `<Divider />`). Replaces the default rule. |
| errorIcon | ReactNode | — | Override the default alert icon for error steps. |
| linear | boolean | false | In linear mode, only the active step + completed steps are clickable. Pending steps beyond the active one get `aria-disabled`. |
| loadingIcon | ReactNode | — | Override the default Spinner for loading steps. |
| onStepClick | ((info: StepperClickInfo) => void) | — | Fires when a clickable step is activated (mouse, touch, Enter, or Space). |
| orientation | ResponsiveValue<StepperOrientation> | — | Layout axis. Accepts a responsive value (e.g. `{ base: 'vertical', md: 'horizontal' }`). |
| showDescriptions | boolean | true | Hide step descriptions even when present. |
| showLabels | boolean | true | Hide all step labels (compact horizontal indicator-only strip). |
| size | ResponsiveValue<StepperSize> | 'md' | Size axis for indicator + typography + connector spacing. |
| steps | StepData[] | — | Step data for the array API. Ignored when compound `<Stepper.Step>` children are present. |
| sx | Sx | — | Theme-aware inline style object. |
| variant | enum | 'numbered' | Visual flavor. |
Status auto-derivation
<Stepper
active={2}
steps={[
{ id: 'a', label: 'Account' }, // auto → complete (index 0 < active 2)
{ id: 'b', label: 'Profile' }, // auto → complete (index 1 < active 2)
{ id: 'c', label: 'Plan' }, // auto → active (index 2 === active 2)
{ id: 'd', label: 'Review' }, // auto → pending (index 3 > active 2)
]}
/><Stepper
active={2}
steps={[
{ id: 'a', label: 'Account' }, // auto → complete (index 0 < active 2)
{ id: 'b', label: 'Profile' }, // auto → complete (index 1 < active 2)
{ id: 'c', label: 'Plan' }, // auto → active (index 2 === active 2)
{ id: 'd', label: 'Review' }, // auto → pending (index 3 > active 2)
]}
/>To override per step, pass status:
{ id: 'c', label: 'Verify', status: 'loading' } // shows Spinner instead of step number
{ id: 'c', label: 'Payment', status: 'error' } // paints red + alert icon{ id: 'c', label: 'Verify', status: 'loading' } // shows Spinner instead of step number
{ id: 'c', label: 'Payment', status: 'error' } // paints red + alert iconThe error / loading / complete overrides always win. Explicit active on a non-active
index quietly downgrades to whichever auto-derived status applies (so consumer-set "active"
can never disagree with the prop-set active index).
Clickability
clickable={false}(default): steps render as plain<span>— no tab stop, no hover affordance.clickable={true}: every step is a focusable<button>. Combine withonStepClick={({ id, index }) => …}to jump.clickable="completed": onlycompletesteps fireonStepClick. The common "tap a finished step to jump back" pattern.linear={true}blockspendingsteps from firingonStepClick. The button is rendered witharia-disabled="true"+ nativedisabledso screen readers announce it and keyboard tabbing skips it.
Variants
| Variant | When |
|---|---|
numbered | Default. Circle with step number; check / alert / Spinner replace it per status. |
dots | Compact rail — useful for narrow surfaces where labels are below the indicators. |
progress | Filled dots-and-line look — the dot itself reads as the active marker. |
Sizes
sm / md (default) / lg. Size controls indicator diameter, label font, and connector vertical offset so the line lines up with the indicator's center across all three sizes.
Accessibility
- Root is
<ol role="list" aria-label="Progress">(override viaaria-label). - Each step is
<li data-stepper-item>witharia-current="step"on the active one. - The indicator's accessible name is auto-composed:
"Step {n} of {total}: {label}, {status}"— screen readers announce position + label + status in one pass. - Status icons (
<Check>for complete,<AlertCircle>for error,<Spinner>for loading) arearia-hidden; the indicator span carries the accessible name viaaria-label. - Separators (the connector lines) carry
role="presentation"+aria-hidden="true"so SR doesn't read "underscore underscore underscore" between every step. - Clickable steps render as
<button type="button">with native focus management; non-clickable steps render as plain spans (no tab stop). Linear-blocked steps getaria-disabled="true"and nativedisabledso SR users hear the restriction. - axe-core: 0 violations across horizontal / vertical / clickable / linear / error / loading + the variant × size matrix.
i18n
The default aria-label="Progress" and the embedded status strings ("complete", "in progress", etc.) are hard-coded English for this phase. When <I18nProvider> ships, the same prop surface will plug into the translation hook — no breaking API change. Until then, override aria-label if you need a localized root name; the per-step status strings live inside the rendered aria-label and currently can't be customized without <I18nProvider>.
RTL
- The
<ol>flows right-to-left automatically insidedir="rtl"— the first step appears on the right in both horizontal and vertical layouts. - Connectors use
margin-inline-startto position the vertical track — it always sits on the logical-start edge. - Step number digits stay LTR (consistent with
tabular-numsand standard form indicators).
Theming
Six override slots: root, item, interactive, indicator, label, description, connector, content.
<ThemeProvider
theme={defineTheme({
components: {
Stepper: {
defaultProps: { variant: 'dots', size: 'sm' },
styleOverrides: {
indicator: 'data-[status=active]:bg-primary data-[status=active]:ring-primary-subtle',
connector: 'data-[status=complete]:bg-primary',
},
},
},
})}
>
{children}
</ThemeProvider><ThemeProvider
theme={defineTheme({
components: {
Stepper: {
defaultProps: { variant: 'dots', size: 'sm' },
styleOverrides: {
indicator: 'data-[status=active]:bg-primary data-[status=active]:ring-primary-subtle',
connector: 'data-[status=complete]:bg-primary',
},
},
},
})}
>
{children}
</ThemeProvider>Composition: wizard pattern
<Stepper> is intentionally presentation-only — it doesn't ship next / prev / submit buttons.
Pair it with the consumer's own button row + form state (or your favorite form library):
function Wizard() {
const [active, setActive] = useState(0);
const isLast = active === STEPS.length - 1;
return (
<>
<Stepper active={active} steps={STEPS} clickable linear
onStepClick={({ index }) => setActive(index)} />
<YourFormForStep active={active} />
<div>
<Button onClick={() => setActive((i) => Math.max(0, i - 1))}>Back</Button>
{isLast
? <Button onClick={submit}>Submit</Button>
: <Button onClick={() => setActive((i) => i + 1)}>Next</Button>}
</div>
</>
);
}function Wizard() {
const [active, setActive] = useState(0);
const isLast = active === STEPS.length - 1;
return (
<>
<Stepper active={active} steps={STEPS} clickable linear
onStepClick={({ index }) => setActive(index)} />
<YourFormForStep active={active} />
<div>
<Button onClick={() => setActive((i) => Math.max(0, i - 1))}>Back</Button>
{isLast
? <Button onClick={submit}>Submit</Button>
: <Button onClick={() => setActive((i) => i + 1)}>Next</Button>}
</div>
</>
);
}The Wizard example above demonstrates this pattern end-to-end.
Do / Don't
- Do keep
activeas a single number index; it's the easiest state to advance. - Do use the array API for data-driven flows; switch to compound when one step needs custom inline content.
- Do combine
clickable+linearfor wizard navigation that lets the user backtrack but not skip ahead. - Don't put next / prev / submit buttons inside Stepper — they belong in your form, not the indicator.
- Don't auto-advance on mount in a renderer example — Ahmad's ship-gate rule: every interactive component must wait for a user action.