Skip to content

Stepper

The step indicator strip for a multi-step flow — Stepper holds the state and navigation, StepperStep renders each step. Status (upcoming/current/complete, or error if you set it) is derived from position, so you only ever manage one number: the current step.

Navigation defaults to strictly linear — a step is only ever clickable if it’s complete or current, so Stepper never lets someone skip ahead to a step they haven’t reached. Pass canNavigateTo to override this for a non-linear flow (e.g. every step the user has already validated, not just the ones before the current position).

Step position can optionally resume across sessions — pass persistKey to opt in. It’s off by default, same as every other persisted piece of state in this package.

Playground

  1. 3Invite your team
  2. 4Confirm
Step 2 of 4, Workspace

Install

npm install @runmarklabs/react
/* your app's CSS entry point */
@import "@runmarklabs/react/stepper/style.css";
import { Stepper, StepperStep } from "@runmarklabs/react/stepper";
<Stepper aria-label="Onboarding progress" step={step} onStepChange={setStep}>
<StepperStep label="Account" description="Create your login" />
<StepperStep label="Workspace" />
<StepperStep label="Confirm" />
</Stepper>;

Props

Stepper

PropTypeDefaultDescription
allowNavigationbooleantrueWhether activating a step's indicator navigates to it at all. Set to `false` for a purely informational, unclickable progress indicator.
aria-labelstringAccessible label for the step list, e.g. "Onboarding progress".
canNavigateTo((index: number, status: StepStatus) => boolean)(_index: number, status: StepStatus) => status === "complete" || status === "current"Per-step reachability predicate, called with each step's index and its computed status. The default only allows navigating to a step that's already `"complete"` or is the `"current"` one — a strictly linear flow. Override this for non-linear navigation (e.g. every step the user has validated so far, not just ones before the current position). Has no effect when `allowNavigation` is `false`, and a step's own `disabled` still wins over this returning `true`.
childrenReactNodeThe `StepperStep` elements, in order. Optional — an empty Stepper renders its container with no steps rather than requiring a caller to always have at least one.
defaultStepnumber0Initial step index in uncontrolled mode.
onStepChange((index: number) => void)Called after the current step changes, controlled or not.
orientation"horizontal" | "vertical"horizontal
persistenceAdapterPersistenceAdaptera fresh `createLocalStoragePersistenceAdapter()`, only constructed when `persistKey` is set.
persistKeystringPersists the current step index so the flow resumes where the user left off on their next visit. Opt-in — omit to keep step position in memory only, the default for every other controllable/uncontrolled component in this package.
stepnumberControlled current step index (0-based). Omit to let the Stepper manage its own state.

StepperStep

label (required), description, error, disabled — plus the usual <li> attributes. See packages/react/src/Stepper/Stepper.tsx for the full signature; StepperStep’s props aren’t in the table above because it also receives internal props from its parent Stepper that aren’t part of what you write.

Accessibility notes

  • aria-current="step" on the current step, not a color alone — status is also conveyed by the indicator’s icon (a checkmark for complete) and position, so it doesn’t depend on color perception.
  • Keyboard navigation is roving-tabindex (@runmarklabs/core’s RovingFocusGroup, the same primitive behind Radix’s own Tabs) — Tab reaches the step strip once, then arrow keys move between reachable steps, Home/End jump to the first/last, and Enter or Space activates the focused one. This follows the WAI-ARIA tabs pattern; the one deliberate deviation is that an unreachable step is never part of the roving-tabindex sequence at all (see the next point), where the tabs pattern would normally still let you arrow onto a disabled tab.
  • A step that isn’t reachable yet isn’t a disabled button — it’s not a button at all, so it’s never announced as an unavailable control a screen reader user might wonder how to unlock.
  • A visually hidden live region announces every step change, including on mount (so resuming from a persisted step announces where you resumed to) — “Step 2 of 4, Workspace,” not just the bare label.
  • Zero axe violations — asserted in packages/react/src/Stepper/Stepper.test.tsx.