Skip to content
Primitiv home
Framework
Consumption mode

Stepper

stableSource Figma

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.

Playground

Density

Preview

Create your account — the email you sign in with.
Size
Orientation
import {  Stepper, StepperList, StepperStep, StepperMarker,  StepperLabel, StepperDescription, StepperPanel,} from "@/components/ui/stepper";
<Stepper size="md" orientation="horizontal" defaultValue="account">  <StepperList label="Setup progress">    {steps.map((step, i) => (    <StepperStep value={step.value} state={step.state}>      <StepperMarker>{i + 1}</StepperMarker>      <StepperLabel>{step.title}</StepperLabel>      <StepperDescription>{step.description}</StepperDescription>    </StepperStep>    ))}  </StepperList>
  {steps.map((step) => (    <StepperPanel value={step.value}>{step.body}</StepperPanel>  ))}</Stepper>

Density is set by a data-density ancestor — the Context system, not a Stepper prop.

Installation

npx 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

<Stepper>  <StepperList>    <StepperStep>      <StepperMarker />      <StepperLabel />      <StepperDescription />    </StepperStep>  </StepperList>  <StepperPanel /></Stepper>

Props

Stepper

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultDescription
activationMode"automatic" | "manual""automatic"When a focused trigger becomes active; see TabsActivationMode.
defaultValuestring—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".
lazyMountboolean—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.
valuestring—Value of the currently active tab.
size"xs" | "sm" | "md" | "lg" | "xl"mdRail size; data-density scales each size further.

StepperList

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultDescription
ariaLabelledBystring—Id of an existing element to use as the tablist's accessible name, set as aria-labelledby. Mutually exclusive with label.
labelstring—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"falseCollapses 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.

PropTypeDefaultDescription
value (required)string—Identifies this trigger, linking it to the Tabs.Content of the same value. Not the visible label — use children for that.
asChildboolean—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.
disabledbooleanfalseRemoves the trigger from the roving tab order and marks it aria-disabled/data-disabled; arrow-key navigation skips it.
refRef<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.

PropTypeDefaultDescription
value (required)string—Value of the trigger this panel belongs to.

Styling contract

--primitiv-stepper-marker-size--primitiv-stepper-label-gap--primitiv-stepper-row-gap--primitiv-stepper-halo-outset--primitiv-stepper-connector-size--primitiv-stepper-connector-color--primitiv-stepper-connector-color-reached--primitiv-stepper-connector-gap--primitiv-stepper-connector-inset--primitiv-stepper-compact-segment-height--primitiv-stepper-compact-gap--primitiv-stepper-marker-background--primitiv-stepper-marker-border-color--primitiv-stepper-marker-border-width--primitiv-stepper-marker-fg--primitiv-stepper-marker-icon-size--primitiv-stepper-mark-size--primitiv-stepper-halo-color--primitiv-stepper-halo-scale--primitiv-stepper-label-color--primitiv-stepper-label-font-family--primitiv-stepper-label-font-size--primitiv-stepper-label-font-weight--primitiv-stepper-label-line-height--primitiv-stepper-description-color--primitiv-stepper-description-font-family--primitiv-stepper-description-font-size--primitiv-stepper-description-font-weight--primitiv-stepper-description-line-height--primitiv-stepper-panel-padding-block--primitiv-stepper-panel-padding-inline--primitiv-stepper-panel-fg--primitiv-stepper-panel-font-family--primitiv-stepper-panel-font-size--primitiv-stepper-panel-font-weight--primitiv-stepper-panel-line-height--primitiv-stepper-tap-target--primitiv-stepper-transition-duration--primitiv-stepper-connector-duration--primitiv-stepper-transition-easing

Keyboard

KeyBehaviour
ArrowRight / ArrowLeftMove focus to the next / previous step, when orientation is horizontal (the default). Under dir="rtl" the pair is mirrored.
ArrowDown / ArrowUpThe same, when orientation is vertical.
Home / EndFirst / last enabled step.
Enter / SpaceActivate the focused step.
TabLeave the rail for the step panel — not move within it.

Data attributes

Stepper

className: .primitiv-stepper

AttributeValueWhen
data-stateactive | inactivethe current step (set by Tabs) / any step that is not current (set by Tabs)
data-step-stateupcoming | complete | errorthe 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-orientationhorizontal | verticalthe 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

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.

Density
Create your account — the email you sign in with.
import {  Stepper, StepperList, StepperStep, StepperMarker,  StepperLabel, StepperDescription, StepperPanel,} from "@/components/ui/stepper";import { Button } from "@/components/ui/button";import { useState } from "react";
const [current, setCurrent] = useState(0);const [furthest, setFurthest] = useState(0);
<Stepper  value={steps[current].value}  onValueChange={(v) => go(steps.findIndex((s) => s.value === v))}>  <StepperList label="Sign-up progress">    {steps.map((step, i) => (      <StepperStep        value={step.value}        state={i < furthest ? "complete" : "upcoming"}        disabled={i > furthest}      >        <StepperMarker>{i + 1}</StepperMarker>        <StepperLabel>{step.title}</StepperLabel>      </StepperStep>    ))}  </StepperList>
  {steps.map((step) => (    <StepperPanel value={step.value}>{step.body}</StepperPanel>  ))}</Stepper>
<Button variant="secondary" onClick={() => go(current - 1)}>Back</Button><Button onClick={() => go(current + 1)}>Continue</Button>

Step states

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.

Density
Review your order before submitting.
import {  Stepper, StepperList, StepperStep, StepperMarker,  StepperLabel, StepperDescription, StepperPanel,} from "@/components/ui/stepper";
<Stepper defaultValue="review">  <StepperList label="Checkout progress">    <StepperStep value="cart" state="complete">      <StepperMarker>1</StepperMarker>      <StepperLabel>Cart</StepperLabel>    </StepperStep>    <StepperStep value="payment" state="error">      <StepperMarker>2</StepperMarker>      <StepperLabel>Payment</StepperLabel>    </StepperStep>    <StepperStep value="review" state="upcoming">      <StepperMarker>3</StepperMarker>      <StepperLabel>Review</StepperLabel>    </StepperStep>  </StepperList>  {/* panels ... */}</Stepper>

A vertical rail

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.

Density
Create your account — the email you sign in with.
import {  Stepper, StepperList, StepperStep, StepperMarker,  StepperLabel, StepperDescription, StepperPanel,} from "@/components/ui/stepper";
<Stepper orientation="vertical" defaultValue="account">  <StepperList label="Setup progress">    {/* steps ... */}  </StepperList>  {/* panels ... */}</Stepper>

Compact (progress bar)

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.

Density

Step 1 of 3 — Account

Create your account — the email you sign in with.
import {  Stepper, StepperList, StepperStep, StepperMarker,  StepperLabel, StepperDescription, StepperPanel,} from "@/components/ui/stepper";import { useMediaQuery } from "@primitiv-ui/react";
const narrow = useMediaQuery("(max-width: 40rem)");
<Stepper value={step} onValueChange={setStep}>  <StepperList label="Setup progress" compact={narrow}>    {/* steps ... */}  </StepperList>
  {narrow && <p>Step {index + 1} of {steps.length} — {steps[index].title}</p>}
  {/* panels ... */}</Stepper>