Multi-step forms are everywhere: onboarding wizards, checkout flows, compliance questionnaires, setup assistants. Despite their ubiquity, most implementations are riddled with accessibility gaps, inconsistent validation, and UX decisions based on guesswork rather than research.
This guide synthesises findings from Baymard Institute, NNGroup, Smashing Magazine, and major design systems (Material Design, Apple HIG, GOV.UK) into a single reference for designing and building multi-step forms that actually work. It covers when to use them, how to design them, how to make them accessible, and how to implement them in React.
Table of Contents
- When to Use Multi-Step vs Single-Page
- How the Industry Does It
- UX Best Practices
- Accessibility Requirements
- Technical Implementation
- Bringing It All Together: A Decision Framework
- Component API Specification
- Storybook Stories Plan
- References
When to Use Multi-Step vs Single-Page
The decision is not about format preference — it is about task complexity and field count. The research converges on clear thresholds.
Use a single-page form when:
- The form has 10 or fewer fields of a single topic
- All fields are closely related (e.g., contact form: name, email, message)
- Users benefit from seeing all fields at once for comparison or context
- The form is a frequent/repeated task where users become experts
Single-page forms achieve 68% completion rates for simple forms but performance drops to 23% above 10 fields (Zuko).
Use a multi-step form when:
- The form has more than 7-10 fields
- Fields span multiple logical topics (personal info, payment, preferences)
- The task is performed infrequently (onboarding, setup, applications)
- Users lack domain expertise and need guidance
- Subsequent steps depend on prior answers (conditional/branching logic)
Multi-step forms average 86% completion for equivalent information requests. Breaking long forms into 3-4 steps increases conversions by up to 300% compared to single-page versions (ConversionXL via Zuko, WeWeb).
Why multi-step works: Breaking a form into chunks leverages George Miller’s “chunking” principle. Each step presents a manageable cognitive unit. Users focus on one information category at a time, reducing context switching. Multi-step forms also leverage progressive engagement and commitment psychology: each completed step reinforces a sense of progress, increasing the likelihood of completion (sunk-cost effect).
It’s Not About the Number of Steps
Baymard Institute’s checkout usability research (272 test sessions, 11,777 quantitative participants) found that users had relatively few problems navigating between steps as long as basic guidelines were followed. The usability issues were caused by what users had to do at each step, not the number of steps or the format. “It’s still much more important what you ask your customers to do during the checkout, rather than if it’s a one-page, accordion style, or traditional linear multi-page checkout process” (Baymard).
Quick Decision Framework
| Criteria | Single-Page | Multi-Step |
|---|---|---|
| Fields <= 7, single topic | Yes | No |
| Fields 8-12, related topics | Maybe | Preferred |
| Fields > 12, multiple topics | No | Yes |
| Conditional/branching logic | No | Yes |
| Infrequent task | Either | Preferred |
| Repeated daily task | Preferred | No |
How the Industry Does It
Material Design (Google)
Material Design defined the most widely-used stepper specification.
Stepper types:
| Type | When to Use |
|---|---|
| Horizontal | Desktop viewports, step content depends on earlier steps |
| Vertical | Narrow/mobile screens, content expands inline below each step label |
| Linear | Steps must be completed sequentially (default) |
| Non-linear | Steps are independent, any order |
| Mobile (dots/text/progress) | Compact indicator for mobile, with separate content area |
Step states:
| State | Circle | Label | When |
|---|---|---|---|
| Active | Number on primary colour | Bold, high opacity | User is here |
| Completed | Checkmark on primary colour | Normal opacity | Step finished |
| Inactive | Number on grey | Low opacity | Not yet reached |
| Error | Warning icon on red | Red text | Validation failed |
| Editable | Pencil icon | Normal opacity | Completed, can revisit |
Key rules from Material Design:
- Do NOT use steppers for short forms (fewer than 3 logical groups)
- Do NOT embed steppers within steppers
- Do NOT use multiple steppers on one page
- Show feedback messages (“Saving…”) only when there is significant latency between steps
- The step indicator should show error states (red circle + warning icon) when a step has validation errors
Important note: The stepper was present in Material Design 1 but was removed from M2 and M3 specifications. MUI continues to maintain it based on M1 guidelines, and it remains the de facto standard. This removal suggests Google considers steppers a solved pattern that does not need further specification — not that they are deprecated.
Apple Human Interface Guidelines (HIG)
Apple takes a deliberately different approach with their Setup Assistant pattern:
- Single centered panel, one focused task per step
- No visible progress indicator for long flows (10+ steps) — avoids overwhelming users with the total
- Strictly linear — no sidebar, no step list
- “Continue” button (never “Next”) in the lower-right (macOS) or full-width bottom (iOS)
- Some steps are skippable via “Set Up Later” or “Skip” text
When Apple uses step indicators:
| Step Count | Indicator |
|---|---|
| 3-7 steps | Dot indicators (Page Controls) |
| 8+ steps | Hide total count; show “Step N” only, or progress bar |
| Variable steps | Progress bar or no indicator at all |
Button conventions:
| Position | Label | Style |
|---|---|---|
| Forward (all steps) | “Continue” | Primary/filled (responds to Return key) |
| Forward (final step) | Action-specific: “Submit”, “Save”, “Done” | Primary/filled |
| Backward | ”Back” | Text/outline (de-emphasised) |
| Skip | ”Set Up Later”, “Not Now” | Text-only link |
Step transitions:
- Horizontal slide: content slides left when advancing, right when going back
- Duration: 200-350ms with ease-in-out
- When
prefers-reduced-motionis active: replace with cross-dissolve (opacity only) or instant swap
Validation approach:
- “Dynamically validate field values” — verify as soon as people enter them
- “Make the Continue button available only after people enter the data you require”
- Show activity indicator in Continue button during async operations
GOV.UK Design System
The gold standard for accessible multi-step forms. Their approach is radically simple.
“One thing per page”:
- Each step is a separate page (server-rendered)
- No complex step indicators — “Question 3 of 9” text at most
- No JavaScript required for core functionality
- Every page has: back link, page heading, continue button
Error handling:
- Error summary at top of page with linked list of all errors
- Inline errors next to each field
- “Error:” prefix added to
<title>so screen readers announce it immediately - Focus moves to error summary when it appears
Key GOV.UK research finding: The Carer’s Allowance team removed a 12-step progress indicator with no effect on completion rates or times. GOV.UK advises against complex progress indicators when the form itself is well-designed.
Headless UI / Unstyled Libraries
There is no standard headless stepper in the React ecosystem:
| Library | ARIA | Validation | Bundle | Notes |
|---|---|---|---|---|
| Stepperize | None (DIY) | None (DIY) | ~1KB | Best type-safety; factory pattern (defineStepper) |
| headless-stepper | Built-in (tablist/tab) | Basic (isValid) | ~1.5KB | Closest to “correct” ARIA, but uses debatable tablist role |
| react-use-wizard | None | handleStep() async | ~2KB | Most popular; children-as-steps pattern |
| Mantine Stepper | Good (button, a11y) | None (external) | Large | Best API reference but styled, not headless |
| shadcn/ui | N/A | N/A | N/A | No official stepper component (open PR since 2023) |
Our recommendation: Build a custom hook + components rather than adopting an external library. The reasons:
- No library provides both good ARIA and good validation integration
- The logic is approximately 100-150 lines of code
- You avoid adding a dependency for trivial logic
- You can integrate directly with your existing component library (Button, Card, Form)
What to borrow from each library:
| Library | Pattern to Adopt |
|---|---|
| Stepperize | String-based step IDs for type safety |
| react-use-wizard | handleStep() async handler pattern, isLoading state |
| headless-stepper | Props-spreading pattern (stepperProps, getStepProps) |
| Mantine | allowStepSelect per step, loading state on step indicator |
| React Aria | FocusScope with autoFocus for step transitions |
Design System Comparison
| Decision | Material Design | Apple HIG | GOV.UK |
|---|---|---|---|
| Step indicator | Numbered circles + connector lines | Dots or none | Text only (“Step N of M”) |
| Click-to-jump | Completed steps only (linear) | No | No |
| Forward button | ”Continue” or “Next" | "Continue" | "Continue” |
| Back button | ”Back” (text/outline) | “Back” (chevron) | “Back” (link) |
| Validation trigger | On “Next” click | On “Continue” click + on blur | On “Continue” click |
| Error display | Inline + step indicator error state | Inline | Error summary + inline |
| Mobile | Vertical stepper or dot/text/progress | Full-screen + dots or hidden | Same as desktop |
| Transitions | Not specified | Horizontal slide (200-350ms) | Page load (no animation) |
UX Best Practices
Step Count and Grouping
Target 3-5 steps. Baymard found the average e-commerce checkout is 5.1 steps with 11.3 fields. Fewer than 3 steps usually means the form is simple enough for single-page. More than 7 steps causes fatigue.
Grouping rules:
- Each step should have a clear, describable purpose (if you cannot name it in 2-3 words, split or merge)
- Front-load easy steps to create early momentum — research shows early slow progress increases abandonment
- Place the review/summary as the final step
- Keep related fields together (do not split “first name” and “last name” into different steps)
Group by mental model, not by data type. Fields should be grouped into steps that represent a natural unit of thought. You would not ask someone about their work experience before asking their name.
Grouping heuristics:
| Step Size | When Appropriate |
|---|---|
| 1-2 fields | High-stakes or confusing input (e.g., payment card number) |
| 3-5 fields | Standard information group (contact details, address) |
| 6-8 fields | Upper limit per step; split if topics diverge |
| 9+ fields | Almost always too many; find a natural split point |
| Dynamic fields | Put in their own step; complexity justifies dedicated UX |
Progress Indication
What works best (research-backed):
| Indicator Type | Best For | Evidence |
|---|---|---|
| Numbered steps with labels | Desktop, 3-7 steps | Gives clear position and context |
| ”Step N of M: [Label]” text | Mobile, any step count | Compact, universally understood |
| Progress bar (percentage) | Many steps, variable-length flows | Shows overall completion |
| Dots | 3-6 equal-weight pages (tutorials, onboarding) | Too imprecise for complex forms |
“Step N of M” outperforms percentage because percentages are misleading when steps vary in complexity. Users expect uniform step duration, so “50% complete” at step 3 of 6 feels right, but “50% complete” with 2 easy steps done and 4 hard steps remaining feels deceptive.
GOV.UK finding: Removing a complex progress indicator had zero impact on completion rates. This suggests that for typical forms (3-5 steps), a simple numbered indicator with labels is sufficient and a complex visualisation adds no value.
Early slow progress causes abandonment: Research on questionnaire completion found that if early feedback indicated slow progress, abandonment rates were higher and users’ subjective experience was more negative (PMC). Front-load easy, quick steps so the progress indicator advances rapidly at the start.
Validation Timing: “Reward Early, Punish Late”
Validation timing is one of the most studied aspects of form UX, and the findings are nuanced.
Luke Wroblewski’s study (2009) showed inline validation delivers: 22% fewer errors, 22% higher success rates, 31% higher satisfaction, 42% faster completion, and 47% fewer eye fixations.
But contradicting evidence from two studies (77 + 90 participants) found users made significantly more errors when errors appeared immediately upon leaving a field. The reason: inline validation forces constant switching between “completion mode” and “revision mode,” splitting focus and increasing cognitive load.
The resolution comes from Vitaly Friedman (Smashing Magazine), who synthesised the research into the “Reward Early, Punish Late” principle:
- Reward Early: When a user fixes an error, remove the error message immediately (on every keystroke). Give instant positive feedback.
- Punish Late: When a user is filling a new or previously-valid field, wait until they leave the field (on blur) before showing errors. Never show errors while the user is still typing.
Baymard’s specific finding: Users frustrated by “overzealous inline validation suggesting they had made a mistake before they even had a chance to type the input correctly.” One participant, when an error appeared on an empty email field: “Why are you telling me my email address is wrong, I haven’t had a chance to fill it all out yet!”
Validation timing by context:
| Scenario | Validation Trigger | Rationale |
|---|---|---|
| New field (user hasn’t interacted) | Do NOT validate | Premature errors are hostile |
| Field the user just left (blur) | Validate on blur | Immediate feedback after input |
| Empty required field | Validate on step transition (“Continue” click) | Don’t nag while user is still filling in |
| Field with active error | Re-validate on every keystroke | Remove error immediately when valid |
| Step transition | Validate all current step fields | Block advancement if invalid |
For multi-step forms specifically: validate each step before allowing advancement to the next step. Waiting until the final step to surface errors from earlier steps is one of the most common and destructive anti-patterns.
Draft Saving and Abandonment Prevention
81% of users abandon forms after starting them. 67% who abandon will not return to complete it. 26% of checkout abandonment is due to the process being too long or complex.
Recommended auto-save strategy:
| Trigger | Action | Priority |
|---|---|---|
| Step change | Save all data to localStorage | Primary mechanism |
| Input change (debounced 3s) | Save all data to localStorage | Safety net for long steps |
| Page/tab visibility change | Save immediately | Catch mobile users switching apps |
| Form submission | Clear saved draft | Cleanup |
| Page load | Restore from localStorage if draft exists | Recovery |
Show “Draft saved [timestamp]” as a subtle status message to reassure users. On return, display “We found a saved draft from [date]. Resume where you left off?” with options to resume or start fresh.
Use the beforeunload event to warn when navigating away from a partially-completed form. Modern browsers limit custom messages, but the generic prompt still prevents accidental loss.
For storage: localStorage is the minimum (cheap, no auth required, survives browser close). Server-side persistence is preferred for authenticated users on high-value forms.
Review / Summary Step
Include by default for forms with 3+ steps. Baymard found that review steps significantly reduce support tickets caused by user error.
Design the summary as:
- Section-based layout (one card per step, not a field-by-field list)
- Read-only text (not disabled inputs — those have poor contrast and accessibility issues)
- “Edit” link per section that navigates back to the corresponding step, then returns to review after editing
- Descriptive final action button: “Submit Complaint”, “Confirm Setup”, not just “Submit”
Anti-patterns in review steps:
- Showing every field individually instead of grouped summaries (creates a wall of text)
- Making the summary non-editable (no way to go back and fix)
- Losing the user’s place after they edit a section (forcing re-navigation through all subsequent steps)
Mobile-Specific Considerations
| Aspect | Recommendation |
|---|---|
| Step indicator | Collapse to “Step N of M: [Label]” text + thin progress bar |
| Navigation | Full-width “Continue” at bottom; “Back” as text in header or above Continue |
| Touch targets | Minimum 44x44px (WCAG 2.5.8) |
| Keyboard | Set inputMode and type attributes correctly (email, tel, url, numeric) |
| Scrolling | Scroll to top on step change |
| Step transitions | Horizontal slide or instant (no complex animations on mobile) |
Never on mobile: Full linear stepper with all labels visible. This is the most common mistake — labels overflow, get truncated, or require tiny font sizes.
Anti-Patterns to Avoid
- Premature validation: Showing errors before the user has interacted with the field
- No progress indicator: Users need to know where they are
- Data loss on browser back: Browser back button should navigate to the previous step, not leave the form
- Heavy animated transitions: Slow transitions feel sluggish and can cause motion sickness
- Allowing forward-jumping to incomplete steps: Creates confusion about what is filled vs empty
- Toasts for validation errors: Disappear, not associated with fields, poor accessibility
- Alert boxes for validation errors: Require dismissal, block interaction
- Different validation UX per form: Users build expectations from the first form they use in your product
Conversion Optimisation
Key findings from the research:
| Change | Impact on Conversion | Source |
|---|---|---|
| Reducing fields from 11 to 4 | +120% | HubSpot |
| Removing 1 field (on average) | +11% | Formstack |
| Improving label clarity (no removal) | +19% | Michael Aagaard |
| Multi-step vs single-page (long form) | +86% | Formisimo |
| Adding a visible progress indicator | +28% | ConversionXL |
Clarity matters more than count. Michael Aagaard reduced a form from 9 to 6 fields and saw a 14% decrease in conversions. When he restored the 9 fields but improved the label clarity (explaining why each field was needed), he saw a 19% uplift — without removing any fields.
Optimisation checklist:
- For each field, ask “Do we need this before the user can get value?” If not, defer it to post-onboarding.
- Front-load easy steps (name, email, company) for early progress momentum.
- Show progress from step 1 — users should see “Step 1 of 3” immediately.
- Explain non-obvious fields with helper text about why the information is needed.
- Track field completion rates. If an optional field has <10% fill rate, consider removing it entirely.
Accessibility Requirements
ARIA Pattern for the Step Indicator
Use <nav> with <ol>, NOT role="tablist".
The tablist/tab pattern implies non-linear access (users expect to freely switch between tabs) and uses arrow-key navigation that conflicts with form field navigation. A step indicator is a navigation landmark containing an ordered list.
<nav aria-label="Progress">
<ol>
<li>
<span aria-current="step">
Step 1: Company Info
<span class="sr-only">(current step)</span>
</span>
</li>
<li>
<span aria-disabled="true">
Step 2: Product Info
<span class="sr-only">(not completed)</span>
</span>
</li>
</ol>
</nav>
Key attributes:
| Attribute | Where | Purpose |
|---|---|---|
aria-current="step" | Active step | Announces “current step” to screen readers |
aria-disabled="true" | Disabled/future steps | Prevents interaction, announces as disabled |
aria-label="Progress" | <nav> element | Provides context for the landmark |
aria-hidden="true" | Connector lines/decorations | Hides decorative elements from AT |
Screen reader support for aria-current="step": Supported by JAWS, NVDA, VoiceOver, and TalkBack. Not supported by Narrator (Edge) or Orca. Always include visually hidden fallback text as well.
Focus Management
On step transition (forward or back): move focus to the step heading.
<h2 id="step-heading" tabindex="-1">
<span class="sr-only">Step 2 of 4: </span>
Product Information
</h2>
function goToStep(newStep: number) {
setCurrentStep(newStep);
requestAnimationFrame(() => {
document.getElementById("step-heading")?.focus();
});
}
Why the heading, not the first field?
- Orients the user: screen reader announces the step title
- Gives the user agency to Tab forward at their own pace
- Matches the pattern when a new page loads (focus goes to the top)
Do NOT trap focus within a step. Unlike modals, multi-step forms are not overlays. Users must be able to Tab to the page header, navigation, and skip links.
Error Handling
Use both error summary (GOV.UK pattern) and inline errors:
<!-- Error summary at top of step (appears on validation failure) -->
<div role="alert" tabindex="-1" id="error-summary">
<h3>There is a problem</h3>
<ul>
<li><a href="#company-name">Enter your company name</a></li>
<li><a href="#company-email">Enter a valid email address</a></li>
</ul>
</div>
<!-- Inline error next to each field -->
<label for="company-name">Company Name</label>
<input
id="company-name"
aria-invalid="true"
aria-describedby="company-name-error"
aria-required="true"
/>
<span id="company-name-error" role="alert">
<span class="sr-only">Error: </span>
Enter your company name
</span>
Error announcement priority:
| Situation | ARIA | Live Region |
|---|---|---|
| Validation errors block step advancement | role="alert" | assertive |
| Step transition announcement | role="status" | polite |
| Form submission success | role="status" | polite |
| Draft auto-saved | role="status" | polite |
Use aria-describedby (not aria-errormessage) for field errors. aria-errormessage has inconsistent support (Narrator and Orca do not support it; NVDA only added support in 2024.3).
Keyboard Navigation
- Tab: Move between interactive elements (fields, buttons) in natural DOM order
- Enter / Space: Activate Next/Previous buttons
- No custom keyboard shortcuts for step navigation (conflicts with screen reader shortcuts)
- Never use
tabindex> 0 — structure the DOM correctly instead
For non-linear steppers that allow click-to-jump:
- Use
<button>or<a>elements for clickable steps - Roving tabindex:
tabindex="0"on focused step,tabindex="-1"on others aria-disabled="true"on unreachable future steps (notdisabledattribute, which can remove elements from the accessibility tree)
Screen Reader Announcements
Two persistent live regions (must exist in DOM from initial render):
<!-- Step transitions (polite) -->
<div
role="status"
aria-live="polite"
aria-atomic="true"
class="sr-only"
id="step-announcer"
></div>
<!-- Errors (assertive) -->
<div
aria-live="assertive"
aria-atomic="true"
class="sr-only"
id="error-announcer"
></div>
function announceStepChange(current: number, total: number, title: string) {
const el = document.getElementById("step-announcer");
if (el) {
el.textContent = "";
requestAnimationFrame(() => {
el.textContent = `Now on step ${current} of ${total}: ${title}`;
});
}
}
WCAG Success Criteria Checklist
| Criterion | Level | Requirement for Stepped Forms |
|---|---|---|
| 1.3.1 Info and Relationships | A | Step indicator uses semantic <ol>, aria-current="step", fields have <label> |
| 1.4.1 Use of Colour | A | Step states conveyed by more than just colour (icons, text) |
| 1.4.3 Contrast | AA | All text/icons meet 4.5:1 (normal) or 3:1 (large) ratio |
| 1.4.11 Non-text Contrast | AA | Step indicator circles meet 3:1 against background |
| 2.1.1 Keyboard | A | All stepper functions operable via keyboard |
| 2.4.3 Focus Order | A | Focus moves logically on step transitions |
| 2.4.6 Headings and Labels | AA | Each step has descriptive heading; buttons describe action |
| 2.5.8 Target Size | AA | Touch targets at least 44x44px |
| 3.3.1 Error Identification | A | Invalid fields marked with aria-invalid, errors described in text |
| 3.3.2 Labels or Instructions | A | Every field has a visible label; format requirements stated |
| 3.3.3 Error Suggestion | AA | Errors explain how to fix, not just what is wrong |
| 4.1.3 Status Messages | AA | Step transitions and errors announced via live regions |
Technical Implementation
Form Library: react-hook-form + Zod
We recommend react-hook-form + Zod as the standard for all multi-step forms:
- A single
useForminstance shared viaFormProvideracross all steps - Per-step validation via
trigger(fields)— validates only current step’s fields - Inline error display via
FormMessage(already accessible witharia-describedbyandaria-invalid)
Pattern: Schema composition with Zod .merge()
// Per-step schemas
const step1Schema = z.object({
companyName: z.string().min(1, "Enter your company name"),
email: z.string().email("Enter a valid email address"),
});
const step2Schema = z.object({
productDescription: z
.string()
.min(10, "Describe your product (min 10 chars)"),
websiteUrl: z.string().url("Enter a valid URL").optional(),
});
// Combined schema for full type inference
const fullSchema = step1Schema.merge(step2Schema);
type FullFormType = z.infer<typeof fullSchema>;
// Step configuration
const steps = [
{ schema: step1Schema, fields: ["companyName", "email"] as const },
{
schema: step2Schema,
fields: ["productDescription", "websiteUrl"] as const,
},
];
Pattern: Per-step validation with trigger()
const methods = useForm<FullFormType>({
resolver: zodResolver(fullSchema),
mode: "onBlur", // validate on blur for "reward early, punish late"
});
async function validateCurrentStep(): Promise<boolean> {
const currentFields = steps[currentStep].fields;
return methods.trigger(currentFields);
}
State Management: Single useForm + Optional Draft Persistence
Use a single useForm instance as the source of truth. No separate useState, no Context, no custom hooks for form data.
The react-hook-form FormProvider already provides:
- Cross-step access to all form data via
useFormContext() - Field-level error tracking
- Dirty/touched state
watch()for reactive data access
Draft persistence layer (optional, per-form):
function useDraftPersistence(formKey: string, methods: UseFormReturn) {
// Restore on mount
useEffect(() => {
const saved = localStorage.getItem(`draft:${formKey}`);
if (saved) methods.reset(JSON.parse(saved));
}, []);
// Save on step change and debounced input
const saveData = useCallback(() => {
localStorage.setItem(
`draft:${formKey}`,
JSON.stringify(methods.getValues()),
);
}, [formKey, methods]);
return {
saveDraft: saveData,
clearDraft: () => localStorage.removeItem(`draft:${formKey}`),
};
}
Step Navigation Hook: useMultiStepForm
Build a custom hook (~100 lines) rather than adopting an external library. The hook manages step state, validation gating, and accessibility announcements. It does NOT manage form data — that is useForm’s job.
interface UseMultiStepFormOptions {
totalSteps: number;
initialStep?: number;
onValidate?: (step: number) => Promise<boolean>;
onStepChange?: (from: number, to: number) => void;
}
interface UseMultiStepFormReturn {
currentStep: number;
totalSteps: number;
isFirstStep: boolean;
isLastStep: boolean;
completedSteps: Set<number>;
nextStep: () => Promise<boolean>;
prevStep: () => void;
goToStep: (step: number) => void;
}
Key behaviours:
nextStep()callsonValidate(currentStep)— if it returnsfalse, the step does not advanceprevStep()always works (no validation on going back)goToStep(n)only works for completed steps (linear constraint)onStepChange(from, to)fires after successful navigation — use for draft saving, analytics- Focus management is handled by the component, not the hook
Zero External Dependencies
The shared stepper adds no new dependencies beyond what a typical React project already has. It uses only:
react-hook-form(form management)zod(validation schemas)class-variance-authority(variant styling)lucide-react(icons)
Bringing It All Together: A Decision Framework
This section synthesises the research into concrete decisions for every aspect of a multi-step form.
Step Indicator
| Decision | Value | Rationale |
|---|---|---|
| Visual style | Numbered circles with labels + connector lines | Most informative; used by Material Design, Mantine, USWDS |
| Click-to-jump | Completed steps only (linear default) | Prevents confusion about filled vs empty steps |
| Mobile | Collapse to “Step N of M: [Label]” text + thin progress bar | Saves space; GOV.UK pattern is effective |
| Breakpoint | Below 640px switches to mobile variant | Standard responsive breakpoint |
| Completed state | Checkmark icon replacing the number | Material Design convention; clear visual signal |
| Error state | Warning icon on destructive colour | Material Design convention |
Navigation Buttons
| Decision | Value | Rationale |
|---|---|---|
| Forward label | ”Continue” | Apple + GOV.UK convention; not “Next” or “Next Step” |
| Back label | ”Back” | Apple convention; not “Previous” |
| Final step label | Action-specific (configurable per form) | “Submit Complaint”, “Confirm Setup”, etc. |
| Placement | Bottom of step content, justify-between | Consistent across all forms |
| Back variant | Text/outline (de-emphasised) | Per Apple and Material Design |
| Forward variant | Primary/filled | Primary action, per Apple and Material Design |
| First step | No Back button rendered (not disabled — absent) | Cleaner; no confusion about what “Back” does on step 1 |
| Loading state | Spinner in forward button | Standard pattern |
Validation
| Decision | Value | Rationale |
|---|---|---|
| Library | react-hook-form + Zod | Strongest validation + type safety |
| Validation timing | On blur (per-field) + on “Continue” click (per-step) | “Reward early, punish late” pattern |
| Error display | Inline below fields | Already accessible in standard Form components |
| Error summary | At top of step on “Continue” validation failure | GOV.UK gold standard for accessibility |
| Toasts for errors | BANNED | Disappear, not associated with fields, poor accessibility |
| Alert boxes for errors | BANNED | Require dismissal, block interaction |
| Empty required fields | Validate on step transition, NOT on blur | Do not nag while user is still filling in |
State Management
| Decision | Value | Rationale |
|---|---|---|
| Form data | Single useForm instance via FormProvider | One source of truth; no split state |
| Step state | useMultiStepForm hook | Separate concern from form data |
| Draft persistence | localStorage by default; configurable | Simple, zero-cost, works offline |
| Draft save trigger | On step change (primary) + debounced on input every 3s (safety net) | Prevents data loss |
Accessibility
| Decision | Value | Rationale |
|---|---|---|
| Step indicator ARIA | <nav> + <ol> + aria-current="step" | USWDS pattern; not tablist/tab |
| Focus on step change | Move to step heading (tabindex="-1") | Orients user; matches page-load behaviour |
| Live regions | Two persistent: role="status" (polite) + aria-live="assertive" | Step transitions + error announcements |
| Field errors | aria-invalid + aria-describedby | Broadest screen reader support |
| Focus trapping | NONE (not a modal) | Users must reach page navigation |
| Keyboard | Tab through fields; Enter triggers Continue | Standard; no custom shortcuts |
Transitions
| Decision | Value | Rationale |
|---|---|---|
| Type | Horizontal slide (translateX) | Apple convention; directional metaphor |
| Duration | 200ms | Fast enough to feel responsive |
| Easing | ease-in-out | Standard; not jarring |
| Reduced motion | Instant swap (no animation) | Respect prefers-reduced-motion |
Component API Specification
<FormStepper> — Step Indicator
interface FormStepperStep {
label: string;
description?: string;
optional?: boolean;
}
interface FormStepperProps {
steps: FormStepperStep[];
currentStep: number;
completedSteps?: Set<number>;
errorSteps?: Set<number>;
onStepClick?: (step: number) => void;
ariaLabel?: string; // defaults to "Progress"
className?: string;
}
Desktop rendering:
<nav aria-label="Progress" class="form-stepper">
<ol class="form-stepper__steps">
<!-- Completed step (clickable) -->
<li class="form-stepper__step form-stepper__step--completed">
<button
type="button"
class="form-stepper__trigger"
aria-label="Step 1 of 3: Company Info, completed"
>
<span class="form-stepper__circle"><CheckIcon /></span>
<span class="form-stepper__label">Company Info</span>
</button>
</li>
<!-- Connector (decorative) -->
<li class="form-stepper__connector" aria-hidden="true">
<span class="form-stepper__connector-line"></span>
</li>
<!-- Active step -->
<li class="form-stepper__step form-stepper__step--active">
<span
class="form-stepper__trigger"
aria-current="step"
aria-label="Step 2 of 3: Product Info, current step"
>
<span class="form-stepper__circle">2</span>
<span class="form-stepper__label">Product Info</span>
</span>
</li>
<!-- Connector -->
<li class="form-stepper__connector" aria-hidden="true">
<span class="form-stepper__connector-line"></span>
</li>
<!-- Inactive step -->
<li class="form-stepper__step form-stepper__step--inactive">
<span
class="form-stepper__trigger"
aria-disabled="true"
aria-label="Step 3 of 3: Review, not completed"
>
<span class="form-stepper__circle">3</span>
<span class="form-stepper__label">Review</span>
</span>
</li>
</ol>
</nav>
Mobile rendering (below 640px):
<div class="form-stepper--mobile" role="group" aria-label="Progress">
<div class="form-stepper__mobile-text">
<span class="sr-only">Step </span>2 of 3: Product Info
</div>
<div
class="form-stepper__mobile-bar"
role="progressbar"
aria-valuenow="2"
aria-valuemin="1"
aria-valuemax="3"
>
<!-- Thin progress bar, 66% filled -->
</div>
</div>
<StepNavigation> — Navigation Buttons
interface StepNavigationProps {
onPrevious?: () => void;
onNext: () => void;
isFirstStep: boolean;
isLastStep: boolean;
nextLabel?: string; // defaults to "Continue"
previousLabel?: string; // defaults to "Back"
isLoading?: boolean;
isNextDisabled?: boolean;
className?: string;
}
<StepContent> — Step Container with Transition
interface StepContentProps {
children: React.ReactNode;
stepHeading: string;
stepNumber: number;
totalSteps: number;
className?: string;
}
Responsibilities:
- Renders a heading with
tabindex="-1"and sr-only step number prefix - Manages focus on mount (moves focus to heading)
- Applies horizontal slide transition (respects
prefers-reduced-motion) - Contains the live region announcer
<ErrorSummary> — Validation Error Summary
interface ErrorSummaryProps {
errors: Array<{ fieldId: string; message: string }>;
heading?: string; // defaults to "There is a problem"
className?: string;
}
Responsibilities:
- Renders error list with links to invalid fields
- Has
role="alert"andtabindex="-1" - Auto-focuses on mount
- Hidden when
errorsarray is empty
Composition Example
Here is how all pieces compose for a typical 3-step form:
function SetupWizard() {
const methods = useForm<FullFormType>({
resolver: zodResolver(fullSchema),
mode: "onBlur",
});
const stepper = useMultiStepForm({
totalSteps: 3,
onValidate: async (step) => {
return methods.trigger(steps[step].fields);
},
onStepChange: (from, to) => {
saveDraft(methods.getValues());
},
});
return (
<Form {...methods}>
<form onSubmit={methods.handleSubmit(onFinalSubmit)}>
<FormStepper
steps={[
{ label: "Company Info" },
{ label: "Product Details" },
{ label: "Review" },
]}
currentStep={stepper.currentStep}
completedSteps={stepper.completedSteps}
onStepClick={stepper.goToStep}
/>
<StepContent
stepHeading={steps[stepper.currentStep].label}
stepNumber={stepper.currentStep + 1}
totalSteps={stepper.totalSteps}
>
{stepper.currentStep === 0 && <CompanyInfoStep />}
{stepper.currentStep === 1 && <ProductDetailsStep />}
{stepper.currentStep === 2 && <ReviewStep />}
</StepContent>
<StepNavigation
onPrevious={stepper.prevStep}
onNext={
stepper.isLastStep
? methods.handleSubmit(onFinalSubmit)
: stepper.nextStep
}
isFirstStep={stepper.isFirstStep}
isLastStep={stepper.isLastStep}
nextLabel={stepper.isLastStep ? "Confirm & Submit" : undefined}
isLoading={methods.formState.isSubmitting}
/>
</form>
</Form>
);
}
Storybook Stories Plan
Component Stories
| Story | Variants | Purpose |
|---|---|---|
FormStepper - Default | 3 steps, active on step 2 | Basic horizontal stepper |
FormStepper - All States | Steps in active, completed, error, inactive states | Visual reference for all states |
FormStepper - Click-to-Jump | 4 steps, first 2 completed, click handlers | Non-linear navigation |
FormStepper - Mobile | Same as default but at mobile viewport | Mobile text + progress bar variant |
FormStepper - Many Steps | 8 steps | Tests horizontal overflow / wrapping |
FormStepper - Optional Step | 3 steps, middle one marked optional | ”Optional” label styling |
StepNavigation - First Step | isFirstStep=true | No Back button |
StepNavigation - Middle Step | Both buttons visible | Standard layout |
StepNavigation - Last Step | Custom nextLabel=“Submit Complaint” | Final action button |
StepNavigation - Loading | isLoading=true | Spinner in Continue button |
StepNavigation - Disabled | isNextDisabled=true | Greyed-out Continue |
ErrorSummary - With Errors | 3 errors with field links | Error summary display |
ErrorSummary - Empty | No errors | Should render nothing |
Interactive Demo Stories
| Story | Description |
|---|---|
MultiStepForm - Basic 3-Step | Complete wizard with validation, all shared components. Text inputs only. |
MultiStepForm - With Draft Persistence | Same as above, plus localStorage draft save/restore. Includes “Draft saved” indicator. |
MultiStepForm - Complex Step | A step with dynamic field lists (add/remove items), select, checkbox group. |
MultiStepForm - Review Step | Summary/review step with “Edit” links back to previous steps. |
Accessibility Testing Stories
| Story | What It Tests |
|---|---|
FormStepper - Keyboard Navigation | Instructions for testing with keyboard only |
MultiStepForm - Screen Reader | Instructions for testing with VoiceOver |
MultiStepForm - Reduced Motion | With prefers-reduced-motion: reduce media query active |
References
Design Systems
- Material Design 1 - Steppers
- MUI - React Stepper
- Apple HIG - Page Controls
- Apple HIG - Entering Data
- Apple HIG - Buttons
- Apple HIG - Motion
- Apple HIG - Onboarding
- GOV.UK - Question Pages
- GOV.UK - Error Summary
- GOV.UK - One Thing Per Page
- USWDS - Step Indicator
- USWDS - Step Indicator Accessibility Tests
- Mantine - Stepper
Headless / React Libraries
- Stepperize
- react-use-wizard
- headless-stepper
- React Aria - FocusScope
- Radix UI - Form
- shadcn/ui Stepper Discussion
- Reka UI Stepper (shadcn-vue upstream)
Accessibility
- W3C WAI - Multi-Page Forms Tutorial
- W3C WAI-ARIA Authoring Practices - Tabs Pattern
- W3C - Understanding SC 4.1.3: Status Messages
- ARIA21 - Using aria-invalid
- Adrian Roselli - Exposing Field Errors
- aria-current best practices (Aditus)
- aria-current support (a11ysupport.io)
- Kendo React Stepper WAI-ARIA
- ESDC - Multi-Step Forms Accessibility
- Consider deprecating aria-errormessage (W3C ARIA #2048)
UX Research
- Smashing Magazine - Creating Effective Multistep Forms (2024)
- Smashing Magazine - A Complete Guide To Live Validation UX
- Baymard Institute - Checkout UX Research
- Baymard Institute - Inline Form Validation
- Luke Wroblewski - Inline Validation in Web Forms (2009)
- NNGroup - Website Forms Usability
- NNGroup - Wizards
- ConversionXL - Multi-Step Form Research
Conversion & Abandonment
- Venture Harbour - How Form Length Impacts Conversion Rates
- Zuko - Single Page or Multi-Step Form
- Zuko - Progress Bars in Online Forms
- Numen Technology - Contact Form Optimization
Mobile & Progress Indicators
- UX Movement - How to Display Steppers on Mobile Forms
- UX Movement - Why Users Make More Errors with Instant Inline Validation
- PMC - The Impact of Progress Indicators on Task Completion
- Breadcrumb Digital - Multi-step Form Design: Progress Indicators