A step rail for a multi-step form — numbered markers joined by connectors, over the headless Tabs. Each marker is a real tab: activation, aria-selected, the roving tabindex, Home/End and disabled all come from Tabs, restyled as a circle. orientation picks the rail direction; sized xs–xl, and data-density scales each size further.
Registry-only
No headless primitive — this component ships only as a copied styled file, so there is no Headless mode. primitiv add stepper installs it whichever mode you are reading.
Playground
Preview
Create your account — the email you sign in with.
Tell us who you are. This is what other people see.
Density is set by a data-density ancestor — the Context system, not a Stepper prop.
Installation
npx primitiv add stepper
pnpm dlx primitiv add stepper
yarn dlx primitiv add stepper
bunx primitiv add stepper
Import
import{Stepper}from"@/components/ui/stepper";
No headless primitive — this one ships only as a copied styled surface, so primitiv add is the only way in, whichever mode you are reading.
Anatomy
Seven parts over the headless Tabs. The rail is a tablist — each StepperStep is a real tab, so aria-selected, the roving tabindex and Home/End all come from Tabs, restyled as a circle. A step carries two independent state hooks: data-state (from Tabs) marks the current step, data-step-state (from StepperStep's state) marks the progress it holds in its own right — so a failed step keeps its error marker while you stand on a later one. Each StepperPanel links to the step of the same value.
Generated from the copied file’s props type and contract.json. Never hand-maintained.
Stepper
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
Description
activationMode
"automatic" | "manual"
"automatic"
When a focused trigger becomes active; see TabsActivationMode.
defaultValue
string
—
Value of the tab active on first render.
dir
"ltr" | "rtl"
—
Reading direction; see TabsReadingDirection. Inherited from the
nearest DirectionProvider when omitted, falling back to "ltr".
lazyMount
boolean
—
When true, a panel's children are not rendered until that tab is first
activated. Once mounted they remain in the DOM across subsequent tab
switches (lazy mount, not unmount-on-hide). Useful for panels that own
expensive initialisation — e.g. a scroll-snap carousel whose initial
scroll position must be set while the panel is visible.
onChange
({ index, name }: TabMetadata) => void
—
Fired on every user-driven activation with the activated tab's metadata.
onValueChange
(value: string) => void
—
Called with the requested value when the user activates a tab.
orientation
"horizontal" | "vertical"
"horizontal"
Layout axis; see TabsOrientation.
value
string
—
Value of the currently active tab.
size
"xs" | "sm" | "md" | "lg" | "xl"
md
Rail size; data-density scales each size further.
StepperList
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
Description
ariaLabelledBy
string
—
Id of an existing element to use as the tablist's accessible name,
set as aria-labelledby. Mutually exclusive with label.
label
string
—
Accessible name for the tablist, announced as aria-label. Pick a
short, human-readable description of the set (e.g. "Account
sections", not "Tabs"). Mutually exclusive with ariaLabelledBy.
compact
"false" | "true"
false
Collapses the rail into a progress bar — the same steps, drawn as segments with their markers and labels hidden. For narrow containers, where a labelled rail cannot fit. The steps stay in the DOM, so each panel keeps the aria-labelledby pointing at its trigger; swapping the rail out for a hand-rolled bar would break that. Drive it from the consumer side (e.g. useMediaQuery), and pair it with your own "Step N of M" line — Stepper owns no step data model.
StepperStep
Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
Description
value* (required)
string
—
Identifies this trigger, linking it to the Tabs.Content of the same
value. Not the visible label — use children for that.
asChild
boolean
—
Render the child element instead of the default <button>. All tab
ARIA attributes and event handlers are merged onto the child. The child
must accept a ref. Useful for routing links that need tab semantics.
disabled
boolean
false
Removes the trigger from the roving tab order and marks it
aria-disabled/data-disabled; arrow-key navigation skips it.
ref
Ref<HTMLElement>
—
Ref to the rendered element. Defaults to HTMLButtonElement; when using
asChild, specify the child's element type (e.g. HTMLAnchorElement).
state
"upcoming" | "complete" | "error"
"upcoming"
The progress this step carries in its own right, published as
data-step-state. Deliberately independent of which step is current —
that is data-state="active", owned by Stepper's value — so a
failed step keeps its error marker while the user stands on a later one.
StepperMarker reads this to decide whether to render its children
(the step number), a tick, or a warning glyph.
- upcoming — Not yet done (the default).
- complete — Done; the marker becomes a tick.
- error — Failed validation; the marker becomes a warning glyph.
StepperMarker
Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.
No props of its own.
StepperLabel
Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.
No props of its own.
StepperDescription
Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.
No props of its own.
StepperPanel
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
Description
value* (required)
string
—
Value of the trigger this panel belongs to.
Styling contract
40 CSS custom properties on .primitiv-stepper — mode-agnostic. These names are the stable surface; the values are not.
The rail is a tablist, so the steps share one tab stop and the arrows move between them. Activation is manual (unlike a plain Tabs): arrow keys move focus along the rail and Enter / Space commits, so browsing the steps never changes step under you and discards what is half-typed in the current panel. Disabled steps are skipped.
Key
Behaviour
ArrowRight / ArrowLeft
Move focus to the next / previous step, when orientation is horizontal (the default). Under dir="rtl" the pair is mirrored.
ArrowDown / ArrowUp
The same, when orientation is vertical.
Home / End
First / last enabled step.
Enter / Space
Activate the focused step.
Tab
Leave the rail for the step panel — not move within it.
Data attributes
Emitted automatically by the headless primitive — style against these rather than adding your own state classes. Grouped by the part that emits them, since most are emitted by more than one.
Stepper
className:.primitiv-stepper
Attribute
Value
When
data-state
active | inactive
the current step (set by Tabs) / any step that is not current (set by Tabs)
data-step-state
upcoming | complete | error
the step is not yet done — the default / the step is done — the marker becomes a tick / the step failed validation — the marker becomes a warning glyph, kept even while a later step is current
data-orientation
horizontal | vertical
the rail runs horizontally — the default (set by Tabs) / the rail runs vertically (set by Tabs)
Accessibility
The rail is a real tablist and every step a tab, so aria-selected, the roving tabindex and Home/End all come from the headless Tabs — nothing here reimplements them. StepperList requires label or ariaLabelledBy: a tablist with no accessible name gives a screen-reader user no idea what the steps belong to.
Activation is manual. Arrow keys move focus along the rail and Enter / Space commits, so browsing the steps with the keyboard never changes step under the user and discards what is half-typed in the current panel.
A step the user cannot reach yet is a disabled step — already a Tabs.Trigger prop, already correct for keyboard and screen readers, so it stays discoverable while being skipped by the arrows. There is no separate linear prop.
StepperMarker's tick and warning glyphs are aria-hidden decoration — the marker does not announce a step's complete / error state. Surface a failed step where it can be acted on: aria-invalid and error text on the fields inside its panel, which is also where a screen-reader user is when they hit the problem.
compact keeps the steps in the DOM (only the markers and labels are hidden with display: none), so each panel's aria-labelledby still points at a real trigger. Swapping the rail out for a hand-rolled bar at narrow widths breaks that reference on exactly the viewport where it is least likely to be noticed.
Examples
Every example below reacts to the density control. Currently showing Styled mode.
A wizard (Back / Continue)
The canonical use: the current step is the Stepper value, so a wizard needs no state beyond that one string. Here Continue advances it and marks the step behind you complete (the marker becomes a tick); steps you have not reached are disabled so they cannot be skipped. Drive validation your way — commit a step only once its fields pass, and set state="error" on one that fails.
Create your account — the email you sign in with.
Tell us who you are. This is what other people see.
state is what a step carries in its own right, independent of which step is current: complete swaps the number for a tick, error for a warning glyph, upcoming keeps the number. It is deliberately separate from data-state="active" (the current step, owned by Tabs) — so a step that failed validation keeps its error marker while you stand on a later one, which a single merged flag could not express.
orientation="vertical" runs the rail top-to-bottom and places the step bodies beside it. The arrow keys follow suit — ArrowUp / ArrowDown move between steps — and it is the shape to reach for when the labels are long or the container is tall and narrow.
Create your account — the email you sign in with.
Tell us who you are. This is what other people see.
compact on StepperList collapses the rail into a segmented progress bar — the same steps, with their markers and labels hidden, for a container too narrow for a labelled rail. Drive it from the consumer side (e.g. useMediaQuery). The steps stay in the DOM, so every panel keeps its aria-labelledby pointing at a real trigger; because the labels are hidden, pair it with your own "Step N of M" line — you already have the index.
Step 1 of 3 — Account
Create your account — the email you sign in with.
Tell us who you are. This is what other people see.