Stepper
A component for showing a user's position in a multi-step task.
The <Stepper> component shows where a user stands in a task that spans several steps, such as a checkout, an onboarding flow, or a multi-page form. Each step reports whether it is done, current, failed, or still ahead, so the progress is visible at a glance and announced to screen readers.
Not the same as the number field stepper
<NumberField> uses the word "stepper" for its increment and decrement
buttons, controlled with hideStepper. The two are unrelated: that one adjusts
a value, this one tracks progress through a task.
Anatomy
A <Stepper> is a navigation landmark wrapping an ordered list. Each step carries a marker, a label, and a connector running to the next step.
Step: One entry in the task, added with
<Stepper.Item>. It is a link when it has anhref, a button when it can be returned to, and plain text when it cannot.Marker: The circle that carries the step's state. It shows the step number, a check once the step is done, or a warning when it failed.
Label: The name of the step, passed as the item's children. It is also the step's accessible name, so it is never optional.
Connector: The line running to the next step. It takes the done colour once the step before it is complete, which is what makes progress readable as a filled trail rather than as four separate circles.
Appearance
The appearance of a component can be customized using the variant and size props. These props adjust the visual style and dimensions of the component, available values are based on the active theme.
| Property | Type | Description |
|---|---|---|
variant | - | The available variants of this component. |
size | - | The available sizes of this component. |
Usage
Reach for a stepper when a task runs over several steps in a fixed order, has a clear end, and is long enough that people benefit from knowing how much of it is left: a checkout, an onboarding flow, a form split across pages. If the sections can be visited in any order they are not steps, so use Tabs instead. If there are only two of them, the progress is already obvious without one.
<Stepper> takes <Stepper.Item> children, one per step, in the order they occur. Every item needs a unique id, and every item needs a label as its children, because the label is the step's accessible name.
The flow owns the state. selectedKey says where the user is, and completedKeys says which steps are done. Marigold never advances the stepper and never marks a step complete on its own: only your code knows whether a step's validation passed or its data was saved.
Do
Use a stepper when the steps run in a fixed order and the task has a clear end
Keep labels to one or two words so the row fits without hiding them
- Let users return to steps they have already finished
Don't
Don't use a stepper as page navigation, or for a task of only two steps where the progress is already obvious.
States
A step is in exactly one state, resolved in this order: disabled, then error, then current, then completed, then upcoming. Each state is carried by a marker shape and a text label as well as by colour, so the meaning never depends on colour alone.
An errored step stays clickable on purpose. A user who is told a step failed needs a way back to it.
The connector is the one part not driven by that single state. It reads completion directly, so a completed step the user has come back to keeps its filled line while its marker shows current. An error or disabled state still clears the line, because a step you have to return to has not cleared the run to the next one.
Driving a multi-step form
Split a form into steps when one page of it would be discouraging, when the fields fall into groups that stand on their own, or when later fields depend on earlier answers. Splitting costs the user a click per step, so a short form is better off on one page. Once it is split, the stepper is what tells people how many steps are left and lets them get back to one they have already filled.
The stepper reports progress, it does not drive it. Your "Continue" button owns both the panel that is shown and the keys handed back to <Stepper>: it adds the step being left to completedKeys and moves selectedKey on. Because completed steps stay clickable, the user can jump back to anything they have already finished without losing it.
Contact
Keep the panel and the stepper in the same source of truth, as the example does with a single index. Deriving one from the other is what stops the marker and the visible content drifting apart.
The example advances on every press so that it stays about the wiring. A real flow should only add a step to completedKeys once that step's data is valid, otherwise the check claims work the user has not done. The validation errors example below adds that part.
Completion does not reset when the user goes back. completedKeys is a set, not a high-water mark, so a step keeps its check while the user is somewhere earlier in the flow. That is what tells them what they have already got through, and it is what keeps those steps clickable. If changing an early answer invalidates a later step, drop that key from completedKeys yourself, because only your code knows which answers depend on which.
Validation errors
When a user submits a step that fails validation, they need to see which step broke and get back to it. errorKeys puts the failure on the stepper itself instead of in a toast that disappears after five seconds. Pass the keys of the steps whose validation failed, and hand the field-level messages to the <Form> as validationErrors so each input explains its own problem.
Two things are worth copying from the example. Clear the step's key as soon as the user edits the field, so the marker and the input never disagree about whether the step is still broken. And remember that an errored step stays selectable by default: if your flow lets people move past a failed step, the red marker is their way back to it.
Which steps can be clicked
By default a user can return to any step they have completed, any step that failed, and the step they are on. Steps still ahead are inert text, not disabled buttons, because they are not controls yet.
Two props change this:
disabledKeysmarks steps that are unavailable. They are never clickable, and nothing overrides that.selectableKeysreplaces the default rule outright. Pass it when a server decides which steps are reachable. It revokes the errored-step guarantee too: a key inerrorKeysthat you leave out ofselectableKeysis marked as broken with no way back to it, so include it unless you mean that.
Reach for disabledKeys when a step is visible but genuinely not available: one that does not apply to this user, such as a payment step on a free plan, or one you have locked after the fact, such as a step whose data can no longer be changed. You do not need it for steps that are merely ahead of the current one, because those are already inert.
<Stepper
aria-label="Booking progress"
selectedKey={currentStep}
completedKeys={completedSteps}
selectableKeys={availableSteps}
>
{steps.map(step => (
<Stepper.Item key={step.id} id={step.id}>
{step.label}
</Stepper.Item>
))}
</Stepper>Links or buttons
When each step of your flow has its own URL, the steps should behave like the links they are, so middle-click, open in a new tab, and copy link all work. Give a reachable <Stepper.Item> an href and it renders as a real link. A step that is still ahead stays inert text even with an href. Without an href, a reachable step renders as a button and only calls onSelectionChange.
A bare href performs a real page load, which is usually not what a single-page app wants. Wrap the tree in a <RouterProvider> and hand it your router's navigate function: the steps stay real anchors for the browser, but clicking one routes client-side. With hrefs the route is the source of truth, so derive selectedKey from it and skip onSelectionChange entirely.
Modifier clicks are deliberately left alone. Cmd-click and middle-click still open a new tab, because that is what a link is for.
Long flows
When there are more steps than labels will fit, the row overflows its container and the labels become unreadable anyway. hideLabels drops them visually while keeping them for screen readers. Do not remove the labels from your JSX to achieve this: an unlabelled step has no accessible name.
Hiding the labels also takes away a sighted user's sense of how far along they are, so hideLabels adds a visible "Step 3 of 5" counter in exchange. The counter is hidden from assistive technology, because every step already announces its own position and the counter would only repeat it.
Accessibility
- The stepper renders a
<nav>landmark around an ordered list, so screen reader users can jump to it or past it. Give it anaria-labelwhen a page has more than one landmark, otherwise it falls back to a localized "Progress". - Avoid labelling both the stepper and a
<Panel>wrapped directly around it. Two nested labelled regions add noise without adding information. - Each step announces its label, its position, and its state, for example "Choose plan Step 2 of 4, current step".
- Activation follows the element: Enter on a link step, Enter or Space on a button step.
- Move focus into the new panel after a step change, to its heading for example. Activating a step swaps the content but leaves focus on the stepper, so without this nothing tells a keyboard or screen reader user that the page moved on.
- Colour transitions are removed when the user prefers reduced motion.
Props
Stepper
Prop
Type
Accessibility props (1)
Prop
Type
Stepper.Item
Prop
Type
Alternative components
Tabs: Use tabs when the sections are at the same level and can be visited in any order. A stepper implies a sequence with a beginning and an end.
Breadcrumbs: Breadcrumbs show where a page sits in a hierarchy. A stepper shows how far along a task is, which is progress rather than location.