Skip to content
Component installs need the registry setup
VegaStack Design

Stepper

A bounded linear process as an ordered list — seven step states on a numbered rail that fills in behind you, with focus that follows the flow.

Status
stable
Since
0.4.0
Accessibility pattern
ordered list with aria-current

Last updated

In transitStep 3 of 4
x

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Stepper before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify stepper

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/stepper

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

The same command installs the registry items it composes: @vegastack/button, @vegastack/progress.

Usage

import { Stepper } from "@/components/ui/stepper";

<Stepper
  aria-label="Import"
  steps={[
    { id: "upload", label: "Upload file", state: "complete" },
    { id: "map", label: "Map columns", state: "current" },
    { id: "review", label: "Review", state: "upcoming" },
  ]}
/>;

Not Tabs. role="tab" announces "tab 2 of 6" and implies free navigation between peers — actively misleading for a flow where step 4 is unreachable until step 3 validates. Stepper is an ordered list with aria-current="step", which is what a wizard actually is. Where a full step list is too heavy, use Progress directly — though below collapse this component already becomes one.

It has no Back and Next, and most uses need none. Reach for Stepper alone when the process is driven by the system and read by a person — an order or delivery tracker, a deployment pipeline, an application's status, a long import running on a server. When a person drives the flow and it needs controls, guards and step bodies, use MultiStepForm, which composes this component and owns the action row.

Scope

It communicates the process, and nothing else.

BehaviourWhere it lives
Back/Next controlsMultiStepForm, or your own buttons
Step bodies and validationMultiStepForm, or your own forms (Field + a validator)
Why the flow will not advanceBeside the control it blocks, never in the rail
Deriving states from an indexHost — states are explicit so failed/skipped steps stay expressible

The refusal message is the one to read twice. Stepper deliberately does not render it: the only place it could sit is under the current step's label, which in a horizontal rail is a one-column-wide ribbon and in the collapsed form does not exist. A reason belongs next to the button it refuses.

Anatomy

Stepper — data-slot="stepper" | "stepper-connector" | "stepper-content" | "stepper-count" | "stepper-description" | "stepper-item" | "stepper-label" | "stepper-list" | "stepper-optional" | "stepper-step" | "stepper-summary" | "stepper-trigger"
StepperNode — data-slot="stepper-node"

Examples

When the host drives it

The buttons below belong to the page, not to Stepper — it renders none. They sit right-aligned with no divider, Back on variant="secondary", which is how MultiStepForm draws its action row too. For anything more than a demo, use that component rather than wiring this by hand.

Map columnsStep 2 of 4
x

Every state

Seven states, and the two that are coloured are coloured because something needs the user. current alone takes the solid primary fill and a medium-weight label; complete is the same hue muted — a check on a light primary tint, with the connector behind it in the same tone — and upcoming is an outline with a muted label. Progress is not success: a rail of green leaves nothing louder for the step that has actually failed. loading is the state an async advance gate occupies while it runs, and skipped records a step passed over rather than one that failed.

  1. Upload fileCompletedcustomers-2026.csv
  2. 2Map columnsCurrent step12 of 12 matched
  3. Check for duplicatesCheckingScanning 8,400 rows
  4. Review changesNeeds review40 rows will be overwritten
  5. Resolve conflictsNeeds attention3 rows need a decision
  6. 6Notify ownersOptionalSkippedPassed over
  7. 7ImportNot started

Error state

The state every hand-rolled stepper forgets: a step that failed after completion. Its node takes the destructive fill, its label the family's -text ink, and the rail behind it does not fill — you have not passed it. When the step that failed is the one the user is on, set current: true alongside state: 'error': it keeps aria-current="step" and the step count while showing the error (MultiStepForm does this for you).

  1. Upload fileCompleted
  2. Map columnsNeeds attention2 columns unmapped
  3. ReviewCompleted
  4. 4ImportCurrent step

Collapsing to a summary

Below collapse the rail becomes the current step's name, its position, and a Progress bar. It is a container query, not a viewport one, so a rail inside a narrow dialog collapses on a wide screen too. Both examples below are the same component with the same props; only the box around them differs.

Map columnsStep 2 of 4
x
Map columnsStep 2 of 4
x

Sizes

  1. Upload fileCompleted
  2. Map columnsCompleted
  3. 3ReviewCurrent step
  4. 4ImportNot started
  1. Upload fileCompleted
  2. Map columnsCompleted
  3. 3ReviewCurrent step
  4. 4ImportNot started

Inline labels

labelPosition="inline" sets each label beside its node with the connector running on from it — a compact rail for a card header. The DOM and the reading order are identical to the default.

  1. CartCompleted
  2. 2DeliveryCurrent step
  3. 3PaymentNot started

Vertical and navigable

orientation="auto" (the default) switches to vertical at verticalFrom steps, because a rail long enough to crush its own labels reads better down the page. In navigable mode, completed, skipped and error steps become real buttons for revisiting — the whole step, node and label together, is one target, so the circle is as clickable as the text — a failed step is exactly the one the user needs to get back to; current, upcoming, and disabled steps never do.

  1. 3ReviewCurrent step
  2. 4ImportNot started

A rail wider than its container

Every step keeps a readable minimum width, so six steps in a narrow container scroll sideways instead of crushing their labels (pass collapse={false} to keep the rail at every width), and the list scrolls the current step into view — on first render too — without moving the page. Revisitable and current steps share one step box — same padding — and the current label's bold width is reserved, so the rail never shifts as the current step moves. A revisitable step shows only the standard hover fill across its node and label, with no press motion.

  1. 3SpecificationsCurrent step
  2. 4FilesNot started
  3. 5AccessoriesNot started
  4. 6ReviewNot started

API Reference

PropTypeDefaultDescription
steps*StepperStep[]—The ordered steps. Exactly one should carry state: "current" or "loading".
aria-labelstring'Progress'Accessible name for the process.
collapsefalse | StepperCollapse'auto'Container width below which the rail is replaced by a compact summary — the current step's label, its position, and a Progress bar. A container query, not a viewport one, so a rail inside a narrow dialog collapses on a wide screen too. auto derives the breakpoint from the step count; a named breakpoint pins it. Applies to horizontal orientation only — a vertical rail already reads correctly when narrow. Pass false to keep the full rail at every width.
labelPosition"below" | "inline"'below'Where the label sits relative to its node, in horizontal orientation only. below gives each step a full-width column under its node; inline sets the label beside the node with the connector running on from it, for a compact rail in a card header. Ignored when vertical.
navigablebooleanfalseNavigable mode: completed, skipped and error steps render as real buttons that fire onStepSelect. In linear mode (the default) no step is interactive — movement belongs to the host's Back/Next controls, and non-interactive steps are a promise that order matters.
onStepSelect((id: string) => void)—Fired in navigable mode when a revisitable step is activated.
orientation"auto" | "horizontal" | "vertical"'auto'Layout direction. auto reads the step count: a rail long enough to crush its own labels side by side is better read down the page, so it flips to vertical at verticalFrom steps. Both orientations keep the same DOM and reading order.
showCountbooleanfalseRenders a "Step n of N" line above the rail. The compact summary always shows it, so this only affects the full rail.
size"default" | "sm"'default'Node and label scale together — default is upstream's tier name, sm is the compact rail for a card header or a toolbar.
verticalFromnumber6How many steps make orientation="auto" choose vertical. Counted from steps, so a conditional step appearing mid-flow can flip the layout by itself.

Data attributes and CSS variables on Stepper

AttributeValues
data-disabled""
data-label-positionmirrors a prop or state value
data-orientationmirrors a prop or state value
data-passed""
data-sizemirrors a prop or state value
data-slot"stepper" | "stepper-connector" | "stepper-content" | "stepper-count" | "stepper-description" | "stepper-item" | "stepper-label" | "stepper-list" | "stepper-optional" | "stepper-step" | "stepper-summary" | "stepper-trigger"
data-statemirrors a prop or state value

Step

PropTypeDefaultDescription
id*string—Stable identifier — selection events return it.
label*string—Visible step label. Receives focus when the step becomes current.
state*StepperStepState—The step's state. Exactly one step should be current or loading.
currentbooleanfalseMarks the step as the current one while its state says something else — the step the user is on has failed validation (error) or carries a caveat (warning). It keeps aria-current="step", the step count and focus-follow on that step, so a failed step never stops being "where am I".
descriptionstring—Optional secondary line under the label.
disabledbooleanfalseMarks a step unreachable even in navigable mode (e.g. gated by a plan).
optionalbooleanfalseMarks the step as one the flow may pass over, rendering an "Optional" affix. It is a label, not a state: an optional step that was passed over is skipped.

Accessibility

  • An <ol> labelled by aria-label, with aria-current="step" on the current item — never tab semantics. A loading step is still the current step.
  • Step state is carried by the node's glyph and visually hidden text ("Completed", "Checking", "Needs review", "Needs attention", "Skipped"), never colour alone.
  • When the current step changes, focus moves to the new step — not the first form field, and never on initial mount. Below the collapse width the rail is not laid out, so focus lands on the summary instead.
  • The compact summary is a real progressbar, named by the current step.
  • A navigable step is one Button wrapping its node and label, named by the label and its state, so it clears the 24px touch floor and takes the system's single focus outline. The connector is decorative and never part of the target.
KeyAction
TabReach completed, skipped and error steps (navigable mode only).
Enter / SpaceRevisit the focused step.
ContractStates tested
Behaviourcomplete, current, loading, warning, error, skipped, upcoming, disabled, navigable, collapsed
Accessibilitykeyboard, labeled, semantic-html, focus-management
Visualcomplete, current, loading, warning, error, skipped, upcoming, disabled, hover

Do / Don't

Do
Model a failed step as state: 'error' with a description — the flow's record should say what needs attention.
Don't
Reach for Tabs because the layout looks similar — tab semantics promise free navigation a wizard doesn't offer.

On this page