Building a Robust React Stepper Wizard in React: Headless, Accessible, and Validated
Build an accessible, headless React stepper wizard with validation, routing, and React Hook Form integration—complete with code you can reuse.
Image used for representation purposes only.
Overview
Steppers (a.k.a. wizards) break complex tasks into digestible steps with clear progress, validation, and the ability to go back and revise. In React, a well-designed stepper should be:
- Headless and composable: state machine logic separated from presentation
- Accessible: navigable by keyboard and screen readers
- Validated per-step: users can only advance when current input is valid
- Resilient: supports async work, conditional steps, and resuming
- Routable: deep-link to a specific step and preserve state on refresh
This article shows how to build a robust, accessible, headless React stepper wizard with a clean API. You’ll get a reusable hook, presentational components, validation with React Hook Form, routing sync, and practical UX and testing tips.
Core design principles
- Headless first: expose state and actions via a hook; let consumers render UI.
- Controlled or uncontrolled: allow callers to manage the active step externally or let the hook handle it.
- Linear with flexibility: default to linear progression, but allow skipping to visited steps.
- Explicit validation: next() should be able to run sync/async validators.
- Accessibility by design: semantic markup, aria attributes, and keyboard support.
Data model and API
A lightweight step model works for most wizards:
export type Step = {
id: string; // stable identifier (for routing and analytics)
label: string; // short, human-friendly step name
optional?: boolean; // allow skip if business logic permits
};
We’ll build a headless hook that returns:
- index, step, steps, isFirst, isLast
- visited and completed sets
- progress (0–1)
- goTo(i), next(), back()
- markComplete(i)
- canGoTo(i) for linear rules
- onValidate callback to block advancing when needed
Headless stepper hook
import { useCallback, useMemo, useRef, useState } from 'react';
export type Step = { id: string; label: string; optional?: boolean };
type UseStepperOptions = {
initial?: number;
linear?: boolean; // if true, users can only jump to visited/previous steps
onValidate?: (index: number) => Promise<boolean> | boolean; // run before next()
controlledIndex?: number; // if provided, index is controlled
onIndexChange?: (index: number) => void; // notify parent on change
};
export function useStepper(steps: Step[], opts: UseStepperOptions = {}) {
const { initial = 0, linear = true, onValidate, controlledIndex, onIndexChange } = opts;
const [uncontrolledIndex, setUncontrolledIndex] = useState(initial);
const index = controlledIndex ?? uncontrolledIndex;
const [visited, setVisited] = useState<Set<number>>(() => new Set([initial]));
const [completed, setCompleted] = useState<Set<number>>(() => new Set());
const total = steps.length;
const isFirst = index === 0;
const isLast = index === total - 1;
const step = steps[index];
const setIndex = useCallback((i: number) => {
if (i < 0 || i >= total) return;
if (controlledIndex == null) setUncontrolledIndex(i);
onIndexChange?.(i);
setVisited((v) => new Set(v).add(i));
}, [total, controlledIndex, onIndexChange]);
const canGoTo = useCallback((i: number) => {
if (i < 0 || i >= total) return false;
if (!linear) return true;
// Linear: can go to any visited step or the immediate next after the furthest visited
const maxVisited = Math.max(...Array.from(visited));
return visited.has(i) || i <= maxVisited + 1;
}, [linear, total, visited]);
const goTo = useCallback((i: number) => { if (canGoTo(i)) setIndex(i); }, [canGoTo, setIndex]);
const back = useCallback(() => setIndex(Math.max(0, index - 1)), [index, setIndex]);
const markComplete = useCallback((i = index) => {
setCompleted((c) => new Set(c).add(i));
}, [index]);
const next = useCallback(async () => {
if (isLast) return;
const ok = onValidate ? await onValidate(index) : true;
if (!ok) return;
markComplete(index);
setIndex(index + 1);
}, [index, isLast, onValidate, markComplete, setIndex]);
const progress = useMemo(() => completed.size / total, [completed.size, total]);
return { steps, index, step, isFirst, isLast, visited, completed, progress, canGoTo, goTo, back, next, markComplete, setIndex } as const;
}
This hook is headless: it manages state and rules without dictating UI. Consumers wire it to any visual design system.
Presentational components
Here’s a minimalist, accessible stepper bar and panel switcher. You can style it with your design tokens.
import React from 'react';
import { Step } from './useStepper';
type StepperBarProps = {
steps: Step[];
active: number;
canGoTo: (i: number) => boolean;
onGoTo: (i: number) => void;
completed: Set<number>;
};
export function StepperBar({ steps, active, canGoTo, onGoTo, completed }: StepperBarProps) {
return (
<nav aria-label="Progress">
<ol role="list" className="stepper">
{steps.map((s, i) => {
const state = i === active ? 'current' : completed.has(i) ? 'complete' : 'upcoming';
const clickable = canGoTo(i);
return (
<li key={s.id} aria-current={i === active ? 'step' : undefined}>
<button
type="button"
className={`step ${state}`}
onClick={() => clickable && onGoTo(i)}
aria-disabled={!clickable}
aria-label={`${s.label} ${state}`}
>
<span className="step-index">{i + 1}</span>
<span className="step-label">{s.label}</span>
</button>
</li>
);
})}
</ol>
</nav>
);
}
export function StepPanels({ index, children }: { index: number; children: React.ReactNode[] }) {
return (
<div>
{React.Children.map(children, (child, i) => (
<section
role="region"
aria-hidden={index !== i}
hidden={index !== i}
>
{child}
</section>
))}
</div>
);
}
Minimal CSS (adjust to your system):
.stepper { display: grid; grid-auto-flow: column; gap: 0.75rem; counter-reset: step; }
.step { display:flex; align-items:center; gap:0.5rem; padding:0.5rem 0.75rem; border-radius:999px; border:1px solid var(--border); background:var(--bg); }
.step.current { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 20%, transparent); }
.step.complete .step-index { background: var(--accent); color: white; }
.step-index { width:1.75rem; height:1.75rem; display:grid; place-items:center; border-radius:999px; border:1px solid var(--border-subtle); }
.step-label { font-weight:600; }
Putting it together: a validated wizard
The example below wires the hook, presentational components, and React Hook Form for per-step validation.
import React from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { useStepper, StepperBar, StepPanels } from './stepper';
type FormValues = {
email: string;
password: string;
firstName: string;
lastName: string;
terms: boolean;
};
const steps = [
{ id: 'account', label: 'Account' },
{ id: 'profile', label: 'Profile' },
{ id: 'confirm', label: 'Confirm' },
] as const;
export default function SignUpWizard() {
const methods = useForm<FormValues>({
mode: 'onTouched',
defaultValues: { email: '', password: '', firstName: '', lastName: '', terms: false },
shouldUnregister: false, // keep values across panels
});
const fieldsByStep: Record<string, (keyof FormValues)[]> = {
account: ['email', 'password'],
profile: ['firstName', 'lastName'],
confirm: ['terms'],
};
const stepper = useStepper(steps, {
linear: true,
onValidate: async (index) => {
const id = steps[index].id as keyof typeof fieldsByStep;
const fields = fieldsByStep[id] ?? [];
const ok = await methods.trigger(fields as any);
return ok;
},
});
const onSubmit = methods.handleSubmit(async (data) => {
// Simulate network request
await new Promise((r) => setTimeout(r, 600));
alert('Submitted: ' + JSON.stringify(data, null, 2));
});
return (
<FormProvider {...methods}>
<form onSubmit={onSubmit} noValidate>
<StepperBar
steps={steps as any}
active={stepper.index}
canGoTo={stepper.canGoTo}
onGoTo={stepper.goTo}
completed={stepper.completed}
/>
<StepPanels index={stepper.index}>
{/* Account */}
<div>
<Field name="email" label="Email" type="email" required />
<Field name="password" label="Password" type="password" required minLength={8} />
</div>
{/* Profile */}
<div>
<Field name="firstName" label="First name" required />
<Field name="lastName" label="Last name" required />
</div>
{/* Confirm */}
<div>
<Checkbox name="terms" label="I agree to the Terms" required />
<pre aria-live="polite" className="preview">{JSON.stringify(methods.getValues(), null, 2)}</pre>
</div>
</StepPanels>
<div className="nav">
<button type="button" onClick={stepper.back} disabled={stepper.isFirst}>Back</button>
{stepper.isLast ? (
<button type="submit">Submit</button>
) : (
<button type="button" onClick={stepper.next}>Next</button>
)}
</div>
</form>
</FormProvider>
);
}
// Basic form fields bound to RHF
import { useFormContext } from 'react-hook-form';
function Field({ name, label, type = 'text', required, minLength }:
{ name: any; label: string; type?: string; required?: boolean; minLength?: number; }) {
const { register, formState: { errors } } = useFormContext();
const rules: any = { required: required && `${label} is required` };
if (minLength) rules.minLength = { value: minLength, message: `${label} must be at least ${minLength} characters` };
return (
<label>
<span>{label}</span>
<input type={type} aria-invalid={!!errors[name]} {...register(name, rules)} />
{errors[name] && <span role="alert">{String(errors[name]?.message)}</span>}
</label>
);
}
function Checkbox({ name, label, required }:{ name:any; label:string; required?: boolean; }) {
const { register, formState: { errors } } = useFormContext();
const rules: any = { required: required && 'You must accept to continue' };
return (
<label>
<input type="checkbox" aria-invalid={!!errors[name]} {...register(name, rules)} /> {label}
{errors[name] && <span role="alert">{String(errors[name]?.message)}</span>}
</label>
);
}
Notes
- shouldUnregister: false keeps off-screen fields mounted in RHF so state persists across steps.
- onValidate triggers step-scoped fields only, ensuring targeted validation and better performance.
Routing and deep-linking
Make steps shareable and restorable via the URL. With React Router v6:
import { useSearchParams } from 'react-router-dom';
function useRoutedStepperIndex(index: number, setIndex: (i: number) => void) {
const [params, setParams] = useSearchParams();
// read
React.useEffect(() => {
const fromUrl = Number(params.get('step'));
if (!Number.isNaN(fromUrl)) setIndex(Math.max(0, fromUrl));
}, []);
// write
React.useEffect(() => {
setParams((p) => { p.set('step', String(index)); return p; }, { replace: true });
}, [index, setParams]);
}
Wire this helper inside your wizard to keep the UI and URL in sync.
Accessibility checklist
- Use a nav/ol for the step list; mark the current item with aria-current=“step”.
- Buttons that cannot be used should have aria-disabled and be keyboard-focusable if appropriate.
- Announce validation errors with role=“alert” and associate messages with inputs via aria-invalid.
- Respect standard keyboard patterns: Tab/Shift+Tab for focus, Enter/Space to activate, Arrow keys if you emulate tabs.
- Provide a persistent, visible page title or h1 that describes the task, not just the step label.
- Maintain focus: move focus to the first invalid field on failed validation, or to the main heading on step change.
Handling async and edge cases
- Async validation: make next() await a validator that hits the server (e.g., unique email). Show a progress indicator on the Next button.
- Guarding navigation: warn users before leaving when dirty; disable step jumps while submitting.
- Conditional steps: compute steps from form values; store completed/visited by step.id, not index, to remain stable if the list changes.
- Optimistic confirmation step: pre-render a summary and update live as values change.
Performance tips
- Lazy-mount heavy panels (tables, maps) only when active; cache results in context if needed.
- Avoid prop-drilling: wrap the stepper API in context for nested components.
- Memoize large child trees; keep form re-renders minimal by scoping fields and using RHF’s Controller when necessary.
Testing strategy
- Unit-test the hook: next/back/goTo rules, visited/completed behavior, linear constraints, and onValidate gating.
- Integration-test the wizard: per-step validation, error messages, keyboard navigation, and URL sync.
- Contract-test async flows: simulate slow network responses and verify spinners, disabled states, and retry paths.
Production hardening checklist
- Analytics: fire events on step view and completion with stable step.id values.
- i18n: keep labels short; support RTL in styles.
- Theming: rely on design tokens; ensure sufficient contrast in current/complete states.
- Error resilience: preserve form state on reload; provide a “Save and resume later” path when feasible.
Conclusion
A great React stepper wizard is more than a row of numbered circles. It’s a small state machine with clear rules, explicit validation, resilient routing, and accessible UI. By separating headless logic (useStepper) from presentation, you can plug the same engine into any design system, add per-step validation with React Hook Form, and scale to complex, conditional flows without losing clarity or performance.
Related Posts
Building an Accessible React Popover and Tooltip Component
Build accessible, high-performance React tooltip and popover components with Floating UI. Includes TypeScript examples, a11y guidance, and testing tips.
How to Build an Accessible React Dropdown Menu (With Code and Testing)
Learn how to build an accessible React dropdown menu: correct ARIA roles, keyboard support, headless component code, testing, and common pitfalls to avoid.
Build an Accessible React Color Picker: A Practical Guide
Build a fast, accessible React color picker from scratch: HSV logic, sliders, popover, keyboard/mouse support, CSS variables, tests, and library options.