Production checklist
Prepare your stepper for production.
Production checklist
You have a working stepper. Before it ships, walk this list. Each item is a real failure mode, with the fix.
Accessibility
If you use the Stepper.* primitives, most of this is already done for you — the
components emit roles, ids, and ARIA. Your job is to not break them.
- Keep the wiring. When you use
render={(props) => ...}, spreadpropsonto your element. Dropping them stripsrole,aria-*, and the click handler. - Trigger a real
<button>.Stepper.Trigger/Next/Prevrender buttons with the righttabIndexandaria-disabled. If you render a custom element, keep it focusable and operable by keyboard. - Arrow keys work.
Stepper.Listhandles←/→(or↑/↓whenorientation="vertical"), plusHome/End, moving focus between triggers with rovingtabIndexwhencanGoTo(id)allows the target. Setorientationto match your layout. - Indicators are decorative.
Stepper.Indicatorisaria-hidden. Put the accessible label inStepper.Title, not the indicator. - Panels are labelled.
Stepper.Contentis atabpanellinked to its trigger. Render oneContentper step so the relationship holds.
What the primitives emit, for reference:
| Element | Role / ARIA |
|---|---|
Stepper.Root | role="group", aria-label="Stepper" |
Stepper.List | role="tablist", aria-orientation |
Stepper.Trigger | role="tab", aria-selected, aria-current, aria-controls |
Stepper.Content | role="tabpanel", aria-labelledby |
Rolling your own markup instead of primitives? You own all of the above. The primitives exist precisely so you don't have to.
SSR, RSC, and Next.js
- Define at module scope.
defineStepper(...)has no side effects and is safe to import anywhere, including Server Components. - Mark interactive components
"use client".useStepper,Provider, and the primitives are client APIs. Any component that calls them must be a Client Component.
// stepper.ts — safe on the server, shared everywhere
export const checkout = defineStepper([...]);"use client";
import { checkout } from "./stepper";
export function Checkout() {
const stepper = checkout.useStepper();
// ...
}- No hydration mismatch. Don't seed the initial step from
window/localStorageduring render. Read persisted state in an effect, thengoTo— or pass it as a server prop.
State & data
- Controlled means you update it. If you pass
step, you must handleonStepChange(same fordata,completed). See Controlled vs uncontrolled. - Recover external step ids. Controlled
stepcan receive raw URL or storage values, but pair it withonInvalidStep. UseparseStepbefore passing external values to typed APIs likegoTo(id). -
awaitnavigation when branching on the result. A bare call is a Promise.if (!stepper.next())always passes. - Disable during transitions. Bind buttons to
canNext/canPrev; they already account forisPending, so async guards can't be double-fired.
Persistence (resume where they left off)
const stepper = checkout.useStepper({
data: values,
onDataChange: (next) => {
setValues(next);
localStorage.setItem("checkout", JSON.stringify(next));
},
});- Persist
values(and optionallystep/completed) on change. - Rehydrate in an effect, not during render (see SSR above).
- For complex step UI that should survive step navigation without a storage round trip, consider React Activity. It keeps inactive step components mounted while preserving Stepperize-controlled navigation.
Analytics & side effects
- Use
onStepChangefor step-view tracking — it fires only on accepted moves.
checkout.useStepper({
onStepChange: (step, { from, direction }) =>
analytics.track("step_change", { from: from.id, to: step, direction }),
});Testing
Stepperize is just React state, so test it like any component.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("advances to payment", async () => {
render(<Checkout />);
await userEvent.click(screen.getByRole("button", { name: /next/i }));
expect(screen.getByRole("tab", { name: /payment/i })).toHaveAttribute("aria-selected", "true");
});- Assert on roles and
aria-selected/aria-current, not on classes — they're stable and they verify accessibility at the same time. - Test a cancelled
beforeStepChangepath: the active step should not change. - For pure logic, the
@stepperize/corehelpers (createStepMap,matchStep) run without React — handy in unit tests and loaders.
Final pass
- Keyboard-only walkthrough: Tab to the list, arrow between steps, Enter to select, Tab into the panel.
- Screen-reader check: each step announces its position and selected state.
- Refresh mid-flow: state restores (if you persist) or resets cleanly.
- Network-throttled: async guards disable buttons instead of double-firing.
Edit on GitHub
Last updated on