StepWizard is a compound component for flows that split naturally into ordered
stages. Reach for it when a single form would be too long to absorb at once,
when later fields depend on earlier answers, or when each stage has its own
validation and you want to gate forward progress on it. Onboarding, deployment
wizards, and key-creation flows are the canonical fit. A plain <Form> is
still the right choice for short inputs the user can see and fill in a single
glance.
The Root owns step registration and active-step state via a reducer, and
exposes it through context. Each <StepWizard.Step> registers itself on mount
(preserving JSX insertion order) and renders inside the Root. Navigation
controls live in your own content — the component does not draw Next/Back
buttons. Call useStepWizard() inside any step to read the current position
or move forward, back, skip, or jump to a specific step by id.
Import
import { StepWizard, useStepWizard } from "@unkey/ui";
StepWizard is a namespace with two members: StepWizard.Root and
StepWizard.Step. Always render steps as direct children of a Root so
registration hits the right context.
Basic
A minimum viable flow: three steps, a shared shell that reads position from
useStepWizard(), and Back/Continue buttons driven by next, back,
canGoBack, and isLastStep.
Completed state
The Root takes an onComplete callback. Calling next() on the last step
fires it instead of advancing, which is the signal to unmount the wizard or
swap in a success view. The example below flips an outer done flag on
completion and shows a summary panel in place of the final step’s content.
Accessibility
StepWizard renders every registered step into the DOM but hides the inactive
ones. The active step is opaque and in flow. Inactive steps are collapsed to
h-0 w-0, given pointer-events: none, and marked aria-hidden="true" so
assistive tech skips them and Tab cannot land inside. Hidden steps keep
their form state mounted, which means a user who navigates back finds their
prior input intact.
The component does not move DOM focus when the active step changes, so
keyboard users who press Continue stay on the Continue button. Two patterns
work well on top of this. First, give the visible region a heading (for
example an <h2> inside each step) and reference it with
aria-labelledby on an outer <section> so screen readers get a fresh
context. Second, when a step contains a form, focus the first invalid field
on submit failure and the first focusable control on mount — a short
useEffect with ref.current?.focus() is enough. Keyboard navigation inside
a step is whatever the step’s own controls provide: Tab/Shift+Tab through
fields, Enter to submit, Escape to cancel the surrounding dialog if the
wizard is nested in one. The wizard itself does not bind global shortcuts, so
it never fights with form semantics.
preventBack on a step disables both the reducer’s GO_BACK transition and
the derived canGoBack flag, which is the right hook for disabling your own
Back button. Use it for irreversible steps (a deployment that has started, a
payment that has been submitted) so the user can’t step backwards into a
stale view.
Props
StepWizard.Root
The provider. Owns the reducer, derives position metadata, and publishes
context to every descendant. Renders a <div className="flex flex-col">
wrapper so steps stack vertically by default.
defaultStepId string Optional Step id to start on. If omitted, the Root activates the first step to register. Pass a value from a URL search param to deep-link into a later step (for example after an OAuth redirect).
onComplete () => void Optional Fires when next() is called on the last step. Use it to close the
wizard, navigate to a success page, or flip a local done flag. The
Root does not unmount itself on completion.
className string Optional Additional Tailwind classes merged onto the outer <div> via cn.
Typical overrides are gap-y-* or a max-width.
children ReactNode Optional One or more <StepWizard.Step> elements. Rendering order determines
step order — the Root preserves insertion order even when steps mount
or unmount conditionally.
StepWizard.Step
A single step. Registers itself with the Root on mount, unregisters on
unmount, and renders its children inside a transitioning container that is
visible only when its id matches activeStepId.
id string Required Stable identifier for this step. Used by goTo, defaultStepId, and
deep links. Must be unique within the Root.
label string Required Human-readable label. Available on StepMeta for progress indicators or
breadcrumbs you render yourself.
kind "required" | "optional" Optional
Default "required" Marks the step as skippable. When kind === "optional", calling
skip() from context advances to the next step; otherwise skip() is
a no-op.
preventBack boolean Optional
Default false When true, the reducer refuses GO_BACK from this step and
canGoBack is false. Use for steps that are irreversible once
entered.
children ReactNode Optional The step content. Call useStepWizard() inside to read position or
drive navigation from within the step.
useStepWizard
Hook that returns the Root’s context. Throws when called outside a
<StepWizard.Root>.
steps readonly StepMeta[] Optional Ordered metadata for every registered step (id, label, kind,
preventBack). Use it to render a progress rail or breadcrumb.
activeStepId string Optional Id of the currently visible step.
activeStepIndex number Optional Zero-based index of the active step within steps. -1 when no step
is registered yet.
totalSteps number Optional Length of steps.
position "empty" | "only" | "first" | "middle" | "last" Optional Semantic placement of the active step. only when a single step is
registered; empty when the Root is mounted with no steps.
next () => void Optional Advances to the next step, or calls onComplete when already on the
last step.
back () => void Optional Goes to the previous step. A no-op on the first step or when the
current step has preventBack.
skip () => void Optional Same as next(), but only fires when the active step has
kind="optional". Use for a “Skip” button that is silently inert on
required steps.
goTo (id: string) => void Optional Jumps to a specific step by id. Silently ignores unknown ids.
canGoBack boolean Optional true when back() would move. Bind this to the disabled prop on
your Back button.
canGoForward boolean Optional true when the active step has a later step to advance to (position
is first or middle).
isFirstStep boolean Optional Shorthand for position === "first" || position === "only".
isLastStep boolean Optional Shorthand for position === "last" || position === "only". Useful for
swapping the primary button label from “Continue” to “Finish”.
registerStep (meta: StepMeta) => void Optional Internal. Called by StepWizard.Step on mount. You should not call
this directly.
unregisterStep (id: string) => void Optional Internal. Called by StepWizard.Step on unmount.
Related
- Form — the right primitive for short, single-page input. Use StepWizard only when the flow genuinely needs stages.
- Dialog — host a short wizard inside a modal when the flow interrupts the page rather than replacing it.