Skip to main content
tutorial Featured

The Complete Guide to Multi-Step Form Design: Research, Accessibility, and Implementation

A research-backed guide to designing and building multi-step forms that convert. Covers UX best practices, WCAG accessibility, validation patterns, and React implementation with react-hook-form and Zod.

BY Group
February 7, 2026
40 min read

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

  1. When to Use Multi-Step vs Single-Page
  2. How the Industry Does It
  3. UX Best Practices
  4. Accessibility Requirements
  5. Technical Implementation
  6. Bringing It All Together: A Decision Framework
  7. Component API Specification
  8. Storybook Stories Plan
  9. 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

CriteriaSingle-PageMulti-Step
Fields <= 7, single topicYesNo
Fields 8-12, related topicsMaybePreferred
Fields > 12, multiple topicsNoYes
Conditional/branching logicNoYes
Infrequent taskEitherPreferred
Repeated daily taskPreferredNo

How the Industry Does It

Material Design (Google)

Material Design defined the most widely-used stepper specification.

Stepper types:

TypeWhen to Use
HorizontalDesktop viewports, step content depends on earlier steps
VerticalNarrow/mobile screens, content expands inline below each step label
LinearSteps must be completed sequentially (default)
Non-linearSteps are independent, any order
Mobile (dots/text/progress)Compact indicator for mobile, with separate content area

Step states:

StateCircleLabelWhen
ActiveNumber on primary colourBold, high opacityUser is here
CompletedCheckmark on primary colourNormal opacityStep finished
InactiveNumber on greyLow opacityNot yet reached
ErrorWarning icon on redRed textValidation failed
EditablePencil iconNormal opacityCompleted, 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 CountIndicator
3-7 stepsDot indicators (Page Controls)
8+ stepsHide total count; show “Step N” only, or progress bar
Variable stepsProgress bar or no indicator at all

Button conventions:

PositionLabelStyle
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-motion is 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:

LibraryARIAValidationBundleNotes
StepperizeNone (DIY)None (DIY)~1KBBest type-safety; factory pattern (defineStepper)
headless-stepperBuilt-in (tablist/tab)Basic (isValid)~1.5KBClosest to “correct” ARIA, but uses debatable tablist role
react-use-wizardNonehandleStep() async~2KBMost popular; children-as-steps pattern
Mantine StepperGood (button, a11y)None (external)LargeBest API reference but styled, not headless
shadcn/uiN/AN/AN/ANo official stepper component (open PR since 2023)

Our recommendation: Build a custom hook + components rather than adopting an external library. The reasons:

  1. No library provides both good ARIA and good validation integration
  2. The logic is approximately 100-150 lines of code
  3. You avoid adding a dependency for trivial logic
  4. You can integrate directly with your existing component library (Button, Card, Form)

What to borrow from each library:

LibraryPattern to Adopt
StepperizeString-based step IDs for type safety
react-use-wizardhandleStep() async handler pattern, isLoading state
headless-stepperProps-spreading pattern (stepperProps, getStepProps)
MantineallowStepSelect per step, loading state on step indicator
React AriaFocusScope with autoFocus for step transitions

Design System Comparison

DecisionMaterial DesignApple HIGGOV.UK
Step indicatorNumbered circles + connector linesDots or noneText only (“Step N of M”)
Click-to-jumpCompleted steps only (linear)NoNo
Forward button”Continue” or “Next""Continue""Continue”
Back button”Back” (text/outline)“Back” (chevron)“Back” (link)
Validation triggerOn “Next” clickOn “Continue” click + on blurOn “Continue” click
Error displayInline + step indicator error stateInlineError summary + inline
MobileVertical stepper or dot/text/progressFull-screen + dots or hiddenSame as desktop
TransitionsNot specifiedHorizontal 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:

  1. Each step should have a clear, describable purpose (if you cannot name it in 2-3 words, split or merge)
  2. Front-load easy steps to create early momentum — research shows early slow progress increases abandonment
  3. Place the review/summary as the final step
  4. 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 SizeWhen Appropriate
1-2 fieldsHigh-stakes or confusing input (e.g., payment card number)
3-5 fieldsStandard information group (contact details, address)
6-8 fieldsUpper limit per step; split if topics diverge
9+ fieldsAlmost always too many; find a natural split point
Dynamic fieldsPut in their own step; complexity justifies dedicated UX

Progress Indication

What works best (research-backed):

Indicator TypeBest ForEvidence
Numbered steps with labelsDesktop, 3-7 stepsGives clear position and context
”Step N of M: [Label]” textMobile, any step countCompact, universally understood
Progress bar (percentage)Many steps, variable-length flowsShows overall completion
Dots3-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:

ScenarioValidation TriggerRationale
New field (user hasn’t interacted)Do NOT validatePremature errors are hostile
Field the user just left (blur)Validate on blurImmediate feedback after input
Empty required fieldValidate on step transition (“Continue” click)Don’t nag while user is still filling in
Field with active errorRe-validate on every keystrokeRemove error immediately when valid
Step transitionValidate all current step fieldsBlock 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:

TriggerActionPriority
Step changeSave all data to localStoragePrimary mechanism
Input change (debounced 3s)Save all data to localStorageSafety net for long steps
Page/tab visibility changeSave immediatelyCatch mobile users switching apps
Form submissionClear saved draftCleanup
Page loadRestore from localStorage if draft existsRecovery

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

AspectRecommendation
Step indicatorCollapse to “Step N of M: [Label]” text + thin progress bar
NavigationFull-width “Continue” at bottom; “Back” as text in header or above Continue
Touch targetsMinimum 44x44px (WCAG 2.5.8)
KeyboardSet inputMode and type attributes correctly (email, tel, url, numeric)
ScrollingScroll to top on step change
Step transitionsHorizontal 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

  1. Premature validation: Showing errors before the user has interacted with the field
  2. No progress indicator: Users need to know where they are
  3. Data loss on browser back: Browser back button should navigate to the previous step, not leave the form
  4. Heavy animated transitions: Slow transitions feel sluggish and can cause motion sickness
  5. Allowing forward-jumping to incomplete steps: Creates confusion about what is filled vs empty
  6. Toasts for validation errors: Disappear, not associated with fields, poor accessibility
  7. Alert boxes for validation errors: Require dismissal, block interaction
  8. Different validation UX per form: Users build expectations from the first form they use in your product

Conversion Optimisation

Key findings from the research:

ChangeImpact on ConversionSource
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:

  1. For each field, ask “Do we need this before the user can get value?” If not, defer it to post-onboarding.
  2. Front-load easy steps (name, email, company) for early progress momentum.
  3. Show progress from step 1 — users should see “Step 1 of 3” immediately.
  4. Explain non-obvious fields with helper text about why the information is needed.
  5. 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:

AttributeWherePurpose
aria-current="step"Active stepAnnounces “current step” to screen readers
aria-disabled="true"Disabled/future stepsPrevents interaction, announces as disabled
aria-label="Progress"<nav> elementProvides context for the landmark
aria-hidden="true"Connector lines/decorationsHides 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:

SituationARIALive Region
Validation errors block step advancementrole="alert"assertive
Step transition announcementrole="status"polite
Form submission successrole="status"polite
Draft auto-savedrole="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 (not disabled attribute, 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

CriterionLevelRequirement for Stepped Forms
1.3.1 Info and RelationshipsAStep indicator uses semantic <ol>, aria-current="step", fields have <label>
1.4.1 Use of ColourAStep states conveyed by more than just colour (icons, text)
1.4.3 ContrastAAAll text/icons meet 4.5:1 (normal) or 3:1 (large) ratio
1.4.11 Non-text ContrastAAStep indicator circles meet 3:1 against background
2.1.1 KeyboardAAll stepper functions operable via keyboard
2.4.3 Focus OrderAFocus moves logically on step transitions
2.4.6 Headings and LabelsAAEach step has descriptive heading; buttons describe action
2.5.8 Target SizeAATouch targets at least 44x44px
3.3.1 Error IdentificationAInvalid fields marked with aria-invalid, errors described in text
3.3.2 Labels or InstructionsAEvery field has a visible label; format requirements stated
3.3.3 Error SuggestionAAErrors explain how to fix, not just what is wrong
4.1.3 Status MessagesAAStep 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 useForm instance shared via FormProvider across all steps
  • Per-step validation via trigger(fields) — validates only current step’s fields
  • Inline error display via FormMessage (already accessible with aria-describedby and aria-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() calls onValidate(currentStep) — if it returns false, the step does not advance
  • prevStep() 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

DecisionValueRationale
Visual styleNumbered circles with labels + connector linesMost informative; used by Material Design, Mantine, USWDS
Click-to-jumpCompleted steps only (linear default)Prevents confusion about filled vs empty steps
MobileCollapse to “Step N of M: [Label]” text + thin progress barSaves space; GOV.UK pattern is effective
BreakpointBelow 640px switches to mobile variantStandard responsive breakpoint
Completed stateCheckmark icon replacing the numberMaterial Design convention; clear visual signal
Error stateWarning icon on destructive colourMaterial Design convention
DecisionValueRationale
Forward label”Continue”Apple + GOV.UK convention; not “Next” or “Next Step”
Back label”Back”Apple convention; not “Previous”
Final step labelAction-specific (configurable per form)“Submit Complaint”, “Confirm Setup”, etc.
PlacementBottom of step content, justify-betweenConsistent across all forms
Back variantText/outline (de-emphasised)Per Apple and Material Design
Forward variantPrimary/filledPrimary action, per Apple and Material Design
First stepNo Back button rendered (not disabled — absent)Cleaner; no confusion about what “Back” does on step 1
Loading stateSpinner in forward buttonStandard pattern

Validation

DecisionValueRationale
Libraryreact-hook-form + ZodStrongest validation + type safety
Validation timingOn blur (per-field) + on “Continue” click (per-step)“Reward early, punish late” pattern
Error displayInline below fieldsAlready accessible in standard Form components
Error summaryAt top of step on “Continue” validation failureGOV.UK gold standard for accessibility
Toasts for errorsBANNEDDisappear, not associated with fields, poor accessibility
Alert boxes for errorsBANNEDRequire dismissal, block interaction
Empty required fieldsValidate on step transition, NOT on blurDo not nag while user is still filling in

State Management

DecisionValueRationale
Form dataSingle useForm instance via FormProviderOne source of truth; no split state
Step stateuseMultiStepForm hookSeparate concern from form data
Draft persistencelocalStorage by default; configurableSimple, zero-cost, works offline
Draft save triggerOn step change (primary) + debounced on input every 3s (safety net)Prevents data loss

Accessibility

DecisionValueRationale
Step indicator ARIA<nav> + <ol> + aria-current="step"USWDS pattern; not tablist/tab
Focus on step changeMove to step heading (tabindex="-1")Orients user; matches page-load behaviour
Live regionsTwo persistent: role="status" (polite) + aria-live="assertive"Step transitions + error announcements
Field errorsaria-invalid + aria-describedbyBroadest screen reader support
Focus trappingNONE (not a modal)Users must reach page navigation
KeyboardTab through fields; Enter triggers ContinueStandard; no custom shortcuts

Transitions

DecisionValueRationale
TypeHorizontal slide (translateX)Apple convention; directional metaphor
Duration200msFast enough to feel responsive
Easingease-in-outStandard; not jarring
Reduced motionInstant 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" and tabindex="-1"
  • Auto-focuses on mount
  • Hidden when errors array 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

StoryVariantsPurpose
FormStepper - Default3 steps, active on step 2Basic horizontal stepper
FormStepper - All StatesSteps in active, completed, error, inactive statesVisual reference for all states
FormStepper - Click-to-Jump4 steps, first 2 completed, click handlersNon-linear navigation
FormStepper - MobileSame as default but at mobile viewportMobile text + progress bar variant
FormStepper - Many Steps8 stepsTests horizontal overflow / wrapping
FormStepper - Optional Step3 steps, middle one marked optional”Optional” label styling
StepNavigation - First StepisFirstStep=trueNo Back button
StepNavigation - Middle StepBoth buttons visibleStandard layout
StepNavigation - Last StepCustom nextLabel=“Submit Complaint”Final action button
StepNavigation - LoadingisLoading=trueSpinner in Continue button
StepNavigation - DisabledisNextDisabled=trueGreyed-out Continue
ErrorSummary - With Errors3 errors with field linksError summary display
ErrorSummary - EmptyNo errorsShould render nothing

Interactive Demo Stories

StoryDescription
MultiStepForm - Basic 3-StepComplete wizard with validation, all shared components. Text inputs only.
MultiStepForm - With Draft PersistenceSame as above, plus localStorage draft save/restore. Includes “Draft saved” indicator.
MultiStepForm - Complex StepA step with dynamic field lists (add/remove items), select, checkbox group.
MultiStepForm - Review StepSummary/review step with “Edit” links back to previous steps.

Accessibility Testing Stories

StoryWhat It Tests
FormStepper - Keyboard NavigationInstructions for testing with keyboard only
MultiStepForm - Screen ReaderInstructions for testing with VoiceOver
MultiStepForm - Reduced MotionWith prefers-reduced-motion: reduce media query active

References

Design Systems

Headless / React Libraries

Accessibility

UX Research

Conversion & Abandonment

Mobile & Progress Indicators

Auto-Save & Draft Patterns

B

BY Group

Software engineering studio building high-quality products with minimal overhead.

Ready to Build Something Great?

Let's discuss your project and bring your ideas to life.

Start a Project

No credit card required • Free forever plan available