Build a stepper / progress tracker
List of steps with statuses (done | current | upcoming | error), driven by a `currentStep` prop or URL. Render an accessible `<ol>` with `aria-current='step'` on the active one. Allow click navigation to completed steps; lock forward navigation behind validation; show progress visually with line/circle states.
A stepper communicates "where am I and what's left" in a multi-step flow. The component is small; the right abstraction matters.
1. API
<Stepper
steps={[{ id: "account", label: "Account" }, { id: "profile", label: "Profile" }, ...]}
current={currentStepId}
completed={["account"]} // or derive
errors={["profile"]} // optional
onStepClick={(id) => goTo(id)} // optional — gated
/>2. Status per step
For each step compute one of: done | current | error | upcoming. That drives both styling and ARIA.
3. Markup — ordered list + aria-current
<ol className="stepper" aria-label="Progress">
{steps.map((s, i) => {
const status = getStatus(s, current, completed, errors);
const isClickable = status === "done" || status === "current";
return (
<li
key={s.id}
className={`step step--${status}`}
aria-current={status === "current" ? "step" : undefined}
>
{isClickable ? (
<button type="button" onClick={() => onStepClick?.(s.id)}>
<span className="step__index" aria-hidden="true">{i + 1}</span>
<span className="step__label">{s.label}</span>
</button>
) : (
<>
<span className="step__index" aria-hidden="true">{i + 1}</span>
<span className="step__label">{s.label}</span>
</>
)}
</li>
);
})}
</ol>4. Navigation rules
- Click on a completed step — allowed (back-navigation is free).
- Click on the current step — no-op.
- Click on an upcoming step — blocked or gated on validation of intervening steps.
- Keyboard — Tab through the clickable steps; Enter/Space activate.
5. Visual states
- Done — checkmark, filled.
- Current — outlined, larger, focus ring.
- Upcoming — muted.
- Error — red, exclamation icon.
- Line between steps reflects status (filled up to the current step).
6. Orientation
Horizontal on desktop, vertical on narrow viewports (CSS-only with @media or a prop).
7. Accessibility
<ol>for ordered semantics.aria-current="step"on the active step (screen readers announce it).- Visible focus on clickable steps.
- Don't rely on color alone — icons + text labels.
- For dense steppers, label each step with its number and name; an
aria-labelon the container ("Checkout progress").
8. Driven by URL
The stepper should read currentStep from the URL so the back button and refresh work, and reflect it via aria-current. The parent owns the navigation logic; the stepper is a view.
Interview framing
"An ordered list (<ol>) with one item per step. Each step has a status (done/current/upcoming/error) computed from the current step id and the completed/errors sets. aria-current='step' on the active one. Click-to-navigate is allowed for completed steps; forward is gated on validation. Visuals follow status — checkmark for done, ring for current, muted for upcoming, red for error — never color-only. The stepper is a view; the parent owns navigation and validation."
Follow-up questions
- •Why use aria-current='step' instead of aria-selected?
- •How do you handle a step that's both completed and has errors (e.g., user edited an earlier step and broke validation)?
- •Horizontal vs vertical — when and how to swap?
Common mistakes
- •Color-only state indication.
- •No keyboard support on clickable steps.
- •Locking back-navigation behind validation.
- •Using <div> instead of <ol>/<li> — loses ordered semantics.
Performance considerations
- •Tiny component; performance not a concern. Make sure status derivation doesn't recompute heavy data — memoize if needed.
Edge cases
- •Many steps overflowing horizontally — collapse to 'Step 3 of 7' on mobile.
- •Step skipped optionally.
- •Step errored after being completed.
Real-world examples
- •Stripe Checkout, Shopify multi-step forms, account onboarding wizards.