Multi-Step Form Wizard in React: Progress, Validation, UX
Build a multi-step form wizard in React with real validation, animated progress bars, and UX patterns that actually keep users from rage-quitting halfway through.
Why Multi-Step Forms Exist (And When to Skip Them)
Honestly, most multi-step forms are built wrong. Developers reach for the wizard pattern the moment a form has more than five fields, slapping a stepper on top and calling it UX. That's not design — that's procrastination disguised as polish.
Multi-step forms genuinely earn their keep in specific situations: onboarding flows where context shifts between sections, checkout sequences where billing and shipping are logically separate, or survey-style forms where earlier answers gate later questions. If you just have twelve fields on a profile page, a single scrollable form with good section headings will outperform a wizard every time.
The real cost of getting this wrong is abandonment. A user who hits step 3 of 7 and realizes the time investment is larger than they expected will leave. Permanently. So before you build the component, decide whether the multi-step pattern is actually earning its complexity.
State Architecture: Where to Store Your Form Data
The foundational decision is where step data lives. You've got three realistic options: local component state per step (data gets lost on unmount), lifted state in a parent component (clean, predictable), or a context-backed store for deeply nested wizards. For 90% of use cases, lifted state in a parent wrapper is the right call.
Here's a pattern that works well. A single useWizard hook owns all form state, the current step index, validation errors, and transition direction. Steps are dumb — they receive values and onChange handlers as props, nothing more. This keeps individual step components testable in isolation.
Don't reach for Redux or Zustand here unless your wizard is genuinely global state that needs to survive navigation between routes. Adding that dependency for a single checkout flow is a tax your future self will resent. Context with useReducer is more than enough.
Building the Step Container and Progress Bar
The progress bar is the first thing users notice. Get it wrong and the whole form feels broken. There are two common approaches: a numeric stepper showing step labels, and a continuous progress bar showing percentage completion. The stepped version works better when each step has a distinct identity. The bar works better when steps vary in length.
Here's a real implementation using Tailwind v4.0.2. The animated fill uses a CSS custom property so you get a smooth transition between any two step values without hardcoding percentages:
interface WizardProgressProps {
currentStep: number;
totalSteps: number;
stepLabels: string[];
}
export function WizardProgress({ currentStep, totalSteps, stepLabels }: WizardProgressProps) {
const percentage = Math.round((currentStep / (totalSteps - 1)) * 100);
return (
<div className="w-full space-y-3">
{/* Continuous bar */}
<div className="h-1.5 w-full rounded-full bg-white/10">
<div
className="h-full rounded-full bg-indigo-500 transition-all duration-500 ease-out"
style={{ width: `${percentage}%` }}
/>
</div>
{/* Step dots */}
<div className="flex items-center justify-between">
{stepLabels.map((label, index) => (
<div key={label} className="flex flex-col items-center gap-1.5">
<div
className={[
"flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold ring-2 transition-all duration-300",
index < currentStep
? "bg-indigo-500 ring-indigo-500 text-white"
: index === currentStep
? "bg-white ring-indigo-400 text-indigo-600"
: "bg-transparent ring-white/20 text-white/40",
].join(" ")}
>
{index < currentStep ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : (
index + 1
)}
</div>
<span className="hidden text-[11px] text-white/50 sm:block">{label}</span>
</div>
))}
</div>
</div>
);
}The ring-2 approach for the active step indicator avoids the z-index stacking issues you'd get with outline. Note the rgba-equivalent white/10 and white/20 Tailwind opacity tokens — they map to rgba(255,255,255,0.1) and rgba(255,255,255,0.2) respectively, keeping things consistent with whatever dark background the component sits on. If you're building glassmorphic UI, check out what glassmorphism actually is before combining these elements.
Step-Level Validation That Doesn't Annoy Users
Validation timing is where most implementations fall apart. Validate too early (on every keystroke before the user finishes typing) and you're slapping error messages on fields that aren't done yet. Validate too late (only on submit) and the user has no idea what went wrong across all the steps they just completed. The sweet spot: validate on blur for individual fields, validate all fields in the current step on 'Next' click.
Use react-hook-form v7 with a resolver (Zod works well) scoped to each step's schema. The trick is passing mode: 'onBlur' at the form level and then calling trigger(stepFields) when the user clicks Next before advancing the step index. This gives you per-field feedback on blur plus a final gate before progression.
One pattern worth stealing: preserve error state across backward navigation. If a user reaches step 4, goes back to step 2 to fix something, and then re-validates, don't wipe the step 4 errors they'd already seen. Users find it disorienting when the form 'forgets' its validation state. Store errors in your parent wizard state, not just in the individual step's local form context.
Animated Step Transitions Without the Jank
Step transitions are a UX detail that most tutorials skip, then developers complain their wizard feels cheap compared to Stripe's or Linear's. The animation needs to communicate direction — moving forward should feel like advancing right, going back should feel like retreating left.
CSS alone handles this fine. You don't need Framer Motion for a form wizard unless you're doing something unusual. A 300ms ease-out translate transition with an 8px starting offset reads as snappy without feeling rushed. Here's the CSS approach:
/* In your global CSS or a CSS module */
.step-enter-forward {
animation: slideInFromRight 300ms ease-out forwards;
}
.step-enter-backward {
animation: slideInFromLeft 300ms ease-out forwards;
}
@keyframes slideInFromRight {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInFromLeft {
from {
opacity: 0;
transform: translateX(-24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}Track a direction value ('forward' | 'backward') in your wizard state and apply the appropriate class to the incoming step. Use a key prop on the step container equal to the step index so React unmounts and remounts it, triggering the animation fresh on each transition. This pattern works reliably across all React versions without needing a library.
Handling the Summary and Final Submit Step
The summary step is where a lot of effort is wasted. Developers either skip it entirely (bad for complex flows) or build an elaborate read-only recreation of every field (overkill for simple ones). What users actually want: a scannable list of the key values they entered, with an edit link that jumps directly to the relevant step.
Don't reconstruct form components in read-only mode for the summary. Just render the values as text with a small 'Edit' button that calls setStep(n). The form component will remount with the stored values from wizard state, and the user can update and click Next to get back to the summary. That's the entire flow — no special 'edit mode' needed.
For the final submit, handle loading and error states explicitly in the wizard. A disabled Next button with a spinner is fine, but also consider what happens on network failure. The user should see the error without losing all their form data. Store the submission error in wizard state, show it on the summary step, and let them retry. Pair this with an animated button to make the submit action feel deliberate.
Accessibility: The Parts That Actually Matter
Can you keyboard-navigate the entire wizard without a mouse? If you built it with standard button elements and proper focus management, probably yes. The common failure point is focus: when a step transition happens, focus stays on the Next button from the previous step — which is now gone or replaced. You need to explicitly move focus to the new step's first interactive element or the step heading.
Use aria-current="step" on the active step indicator. Add role="group" with aria-labelledby to each step panel so screen readers announce which section the user is in. The progress percentage is worth exposing too: a visually hidden live region that announces '2 of 5 steps complete' on each transition costs almost nothing to implement and meaningfully helps users relying on assistive technology.
Don't skip autocomplete attributes on fields like name, email, address. Browsers with saved credentials will autofill across steps if the attributes are correct, and that's a significant UX win for returning users. This one's easy to overlook when you're focused on the custom wizard logic.
Putting It Together with Empire UI Components
Empire UI's component library gives you the building blocks without dictating the wizard logic. The pattern that works well: use Empire UI's card variants as the step container (try the glass card with backdrop-blur-md and rgba(255,255,255,0.08) background), pair with the animated tabs pattern for step navigation when you have fewer than 5 steps, and use the input/button primitives inside each step for visual consistency.
For multi-step flows where each step is a distinct content type — like an onboarding that goes account details → preferences → integrations — you might find the animated tabs component pattern maps naturally to a wizard with a small step count. The tab underline indicator can double as a progress metaphor with minor customization.
If your wizard includes a final confirmation step that surfaces a list of what the user entered, the cards stack pattern gives you a nice layered summary layout. It's not an obvious pairing but it reads well when each card represents a step's worth of data stacked and revealed on the summary screen. The 40 visual styles in Empire UI mean you can match whatever dark or light theme your product uses without fighting the defaults.
FAQ
react-hook-form v7 is the better choice for wizards. Its uncontrolled input model means less re-rendering across steps, and the trigger() API lets you validate only the current step's fields before advancing. Formik works but you'll write more boilerplate to scope validation to a single step.
Store all step values in a parent wizard state object, not in each step's local state. When a step mounts, pass it the stored values as defaultValues. When the user changes a field, update the parent state. With react-hook-form, use defaultValues prop and reset the form when the step mounts using reset(storedValues) inside a useEffect.
Use URL search params like ?step=2 rather than route segments like /wizard/step/2. Search params don't trigger a full navigation, they're easier to push/replace without creating unwanted browser history entries, and you can keep wizard state in memory while still making each step bookmarkable. Next.js 14+ useSearchParams or React Router's useSearchParams both handle this cleanly.
Maintain a steps array that's derived from your wizard state, not hardcoded. A simple filter over all possible steps using a shouldShow(wizardState) predicate per step gives you conditional logic without complex branching. Recalculate this array whenever wizard state changes so the progress indicator and step count stay accurate.
Only if your steps are doing expensive work on render. Wrap individual step components in React.memo and make sure you're not creating new object/array references on every parent render. In practice, most wizard steps are simple forms and re-render cost is negligible. Profile before optimizing — memo adds maintenance overhead that often isn't justified.
Test each step as a unit by rendering just that step component with mock values and handlers. For integration tests of the full wizard flow, render the wizard wrapper and interact via user-event: fill fields, click Next, assert the next step renders, repeat. Avoid snapshot testing wizard steps — they're fragile and don't catch interaction bugs.