Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Component guides
  4. React Multi Step Form with Zod, Saved Drafts and Motion
Auf Deutsch lesen
  1. Blog
  2. Component guides
  3. React Multi Step Form with Zod, Saved Drafts and Motion
Component guides
Component guides

React Multi Step Form with Zod, Saved Drafts and Motion

SR
Serban Rusu · Founder of Wingo UI
Oct 13, 2026 · 12 min read

On this page

0%
  1. What does a good React multi step form need?
  2. How do I split a zod schema into steps?
  3. How do I validate one step at a time with react-hook-form?
  4. How do I add an async check to one step?
  5. How do I save progress across reloads?
  6. How do direction-aware transitions work?
  7. What about a progress bar on phones?
  8. What is the final code?
  9. Can I build a shadcn multi step form instead?
  10. Where should you start?
  11. FAQ
    1. How do I validate only the current step in react-hook-form?
    2. Should a multi step form use one useForm or one per step?
    3. How do I save multi step form progress across a page reload?
    4. How do I add a progress bar to a React multi step form?
    5. Does shadcn/ui have a multi step form component?

TL;DR

Keep one react-hook-form instance for the whole React multi step form, give each step its own zod object, and on Continue call trigger() with only that step's field names. Run async checks such as a username lookup after the sync rules pass, save the values and the current step to localStorage on every change, and slide the next step in from the side the user is heading. The Wingo UI Form Wizard does the step logic, focus and transitions; the final code below adds the zod schemas, the async check and the saved draft.

Published Oct 13, 2026

A React multi step form looks like a list of forms with a Next button between them. The work hides in the gaps: Continue has to validate only the fields on screen, a server check needs a spinner and an error in the right place, a reload should not throw away three steps of typing, and the next step should slide in from the side the user is heading. This tutorial builds a four step workspace signup that handles all of it with react-hook-form, zod and the Form Wizard from Wingo UI, the library we build. Stepper visuals get their own guide in the React stepper component post.

What does a good React multi step form need?

It needs seven things: per-step validation, async checks that run after the sync ones, focus that follows the user, visible progress, saved progress, direction-aware motion and one final submit. Here is what each means in practice:

  • Per-step validation. Continue checks the current step only. Back never validates, because nobody should have to fix a field before they can go back and look at something.
  • Async checks after sync ones. A "Is this URL taken?" request runs only when the format is already right, and the button shows that something is happening.
  • Focus that follows the user. A failed step focuses the first invalid field. A new step moves focus to its heading, and a screen reader hears "Step 2 of 4: Workspace".
  • Progress you can see. A stepper on wide screens, a progress bar with "Step 2 of 4" on phones.
  • Saved progress. Values and the current step survive a reload or a closed tab.
  • Direction-aware motion. Forward slides left, Back slides right, and reduced motion turns both into a crossfade.
  • One submit. The last step sends a single object that the server validates again.

The Form Wizard covers the step state, focus, the stepper and the transitions. You bring the fields, the schemas and the draft storage. Install it with the fields this tutorial uses:

bash
npx wingo-ui@latest add form-wizard input radio-group checkbox form-validation

The CLI also installs react-hook-form, @hookform/resolvers and zod, which the validation helpers depend on.

Press Continue on an empty step: the marker turns into a red "!", the button shakes and focus jumps to the first invalid field. Fill it in and go forward, then press Back and watch the slide reverse
$ npx wingo-ui@latest add form-wizard
ProForm Wizard docs

How do I split a zod schema into steps?

Write one zod object per step and merge them into the schema for the whole form. Each step's field names then come straight from its own schema, so a field added to a step is validated by that step without a second list to keep in sync. This is the core of a zod multi step form:

tsx
import { z } from "zod";
import { createValidators } from "@/lib/form-validation";
const v = createValidators();
const accountSchema = z.object({
name: v.name(),
email: v.email(),
});
const workspaceSchema = z.object({
workspace: v.text({ min: 3, max: 40 }),
slug: z
.string()
.trim()
.regex(/^[a-z0-9-]{3,30}$/, { error: "Use 3 to 30 lowercase letters, digits or dashes" }),
});
const teamSchema = z.object({
teamSize: z.string().min(1, { error: "Pick a team size" }),
});
const reviewSchema = z.object({
terms: v.accept(),
});
const signupSchema = z.object({
...accountSchema.shape,
...workspaceSchema.shape,
...teamSchema.shape,
...reviewSchema.shape,
});
type Signup = z.input<typeof signupSchema>;
const fieldsOf = (schema: z.ZodObject) => Object.keys(schema.shape) as (keyof Signup)[];

createValidators comes from the form validation helpers: zod rules for names, emails, passwords, phone numbers and a ticked checkbox that share one set of English messages. Plain zod works the same way if you prefer your own rules. The examples use zod 4, where the message option is error.

How do I validate one step at a time with react-hook-form?

Use one useForm for the whole form and call trigger() with the current step's fields when the user presses Continue. The react-hook-form docs for trigger (opens in a new tab) describe it as returning a promise that resolves true when the targeted fields pass. With the zod resolver the full schema still runs, but only the fields you name get errors, so step 1 never shows messages for step 3.

A single form instance is also what makes this a react hook form wizard instead of four forms. The library's own wizard example (opens in a new tab) uses a separate store between pages, which fits a multi-page flow with routes. Inside one page you do not need it: per the useForm docs (opens in a new tab), shouldUnregister defaults to false, so a field keeps its value after its step unmounts.

Each step registers its validator with useFormWizardValidation. The wizard runs it on Continue, on Enter in a single-line field and before a forward jump in the stepper:

tsx
function AccountStep() {
const { register, trigger, formState } = useFormContext<Signup>();
useFormWizardValidation(() => trigger(fieldsOf(accountSchema)));
return (
<div className="flex flex-col gap-4">
<Input label="Full name" autoComplete="name" error={formState.errors.name?.message} {...register("name")} />
<Input label="Work email" type="email" autoComplete="email" error={formState.errors.email?.message} {...register("email")} />
</div>
);
}

When the validator resolves false, the wizard marks the step with an error, shakes Continue and focuses the first element with aria-invalid="true" in the step. Wingo UI fields set that attribute whenever they get an error, through the shared Field anatomy, so focus lands on the right field without a ref per input. Controls that are not plain inputs, like a radio group, go through Controller and pass fieldState.error?.message to error.

useZodForm from the same helper file is useForm with the zod resolver and mode: "onTouched": an error appears after the user leaves a field, then updates on every keystroke until it is fixed.

How do I add an async check to one step?

Run the cheap rules first, then the request, and put the answer on the field with setError. The validator returns a promise, so the wizard keeps the user on the step until it settles:

tsx
useFormWizardValidation(async () => {
if (!(await trigger(fieldsOf(workspaceSchema)))) return false;
try {
if (await isSlugFree(getValues("slug"))) return true;
setError("slug", { message: "That URL is taken. Try another one." });
} catch {
setError("slug", { message: "We could not check this URL. Try again." });
}
return false;
});

Three details matter here. The format check stops a typo from reaching the network. The catch turns a failed request into a message instead of a silent block. And the wizard only shows a spinner on Continue once a validator has run for 120ms, so the instant trigger() calls never flash one. The manual error stays until the field validates again. With mode: "onTouched" that happens on each keystroke only after the field has lost focus once, so someone who typed the URL and pressed Enter keeps seeing "taken" while they edit, until they tab away or press Continue again. To clear it as soon as they type, register the field with register("slug", { onChange: () => clearErrors("slug") }).

The check here is a convenience. Two people can claim the same URL between the check and the submit, so the server must enforce uniqueness and the final submit must handle a conflict.

How do I save progress across reloads?

Write the values and the current step to localStorage on every change, and read them back after the component mounts. Reading during render would make the server HTML differ from the client's and break hydration, so the wrapper renders a placeholder of the same height until the draft is known:

tsx
export function SignupWizard() {
const [draft, setDraft] = useState<Draft | null>();
useEffect(() => setDraft(readDraft()), []);
if (draft === undefined) return <div className="min-h-[480px]" />;
return <SignupForm draft={draft} />;
}

Inside the form, watch(callback) subscribes to changes without re-rendering on each one. The current step is controlled through value and onValueChange, so it can be saved with the values. When the wizard mounts on a later step, it counts the steps before it as completed, and the stepper shows them with a check.

Keep secrets out of the draft. A password, a card number or an ID number should never land in localStorage, where any script on the page can read it. If a step collects one, leave that field out of what you save and ask for it again.

How do direction-aware transitions work?

The next step enters from the side you are heading: forward slides in from the right, Back from the left. The Form Wizard compares the new step's index with the old one, slides 32px in that direction, and lets the card's height follow on a spring so the footer does not jump. That height change is a motion layout animation: it measures once and animates a transform, so no layout runs per frame. With prefers-reduced-motion it crossfades instead, and animate={false} swaps steps with no motion at all.

If you build your own, the one trick is to pass the direction to AnimatePresence as well as to the step. The step that is leaving has already left your tree, so it only receives the new direction through AnimatePresence's custom prop:

tsx
const slide: Variants = {
enter: (direction: number) => ({ x: direction * 32, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (direction: number) => ({ x: direction * -32, opacity: 0 }),
};
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
<motion.div key={index} custom={direction} variants={slide} initial="enter" animate="center" exit="exit">
{steps[index]}
</motion.div>
</AnimatePresence>;

Animate x and opacity only. A step change that animates height or width directly runs layout on every frame, which is what makes cheap wizards stutter on mid-range phones. More presets live in our spring presets post.

What about a progress bar on phones?

Use one. Below 768px a row of five numbered circles does not fit with readable titles, so on phones build the React multi step form with progress bar and a step count instead. The Form Wizard switches on its own: under 768px every stepper becomes a compact header with "Step 2 of 4", the step title and a bar. On wider screens you choose with stepper: "horizontal" (numbered markers), "vertical", "progress", "dots" or "none".

With fullScreenOnMobile, the default, the wizard fills its container on a phone: a sticky header, a body that scrolls on its own, and a sticky footer with 48px buttons padded for the home indicator. Give the container a height, such as h-dvh md:h-auto on the wizard itself. For a stepper outside a form, such as a checkout that spans several routes, use the standalone Steps component, covered in the stepper guide.

What is the final code?

The whole signup, about 270 lines, with the four steps, the async URL check, the summary with Edit links and the saved draft:

tsx
"use client";
import { useEffect, useState } from "react";
import { Controller, FormProvider, useFormContext, useWatch } from "react-hook-form";
import { z } from "zod";
import { FormWizard, FormWizardSummary, useFormWizardValidation, type WizardStep } from "@/components/ui/form-wizard";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { RadioGroup, type RadioOption } from "@/components/ui/radio-group";
import { createValidators, useZodForm } from "@/lib/form-validation";
const v = createValidators();
// one zod object per step
const accountSchema = z.object({
name: v.name(),
email: v.email(),
});
const workspaceSchema = z.object({
workspace: v.text({ min: 3, max: 40 }),
slug: z
.string()
.trim()
.regex(/^[a-z0-9-]{3,30}$/, { error: "Use 3 to 30 lowercase letters, digits or dashes" }),
});
const teamSchema = z.object({
teamSize: z.string().min(1, { error: "Pick a team size" }),
});
const reviewSchema = z.object({
terms: v.accept(),
});
// the whole form is the steps merged
const signupSchema = z.object({
...accountSchema.shape,
...workspaceSchema.shape,
...teamSchema.shape,
...reviewSchema.shape,
});
type Signup = z.input<typeof signupSchema>;
const EMPTY: Signup = { name: "", email: "", workspace: "", slug: "", teamSize: "", terms: false };
// the field names a step owns, read from its schema
const fieldsOf = (schema: z.ZodObject) => Object.keys(schema.shape) as (keyof Signup)[];
const TEAM_SIZES: RadioOption[] = [
{ value: "solo", label: "Just me" },
{ value: "small", label: "2 to 10 people" },
{ value: "large", label: "More than 10" },
];
function AccountStep() {
const { register, trigger, formState } = useFormContext<Signup>();
useFormWizardValidation(() => trigger(fieldsOf(accountSchema)));
return (
<div className="flex flex-col gap-4">
<Input
label="Full name"
autoComplete="name"
enterKeyHint="next"
error={formState.errors.name?.message}
{...register("name")}
/>
<Input
label="Work email"
type="email"
autoComplete="email"
enterKeyHint="next"
error={formState.errors.email?.message}
{...register("email")}
/>
</div>
);
}
async function isSlugFree(slug: string) {
const response = await fetch(`/api/workspaces/available?slug=${encodeURIComponent(slug)}`);
if (!response.ok) throw new Error(`Availability check failed: ${response.status}`);
const data = (await response.json()) as { available: boolean };
return data.available;
}
function WorkspaceStep() {
const { register, trigger, getValues, setError, formState } = useFormContext<Signup>();
useFormWizardValidation(async () => {
// the cheap checks first, so a typo never reaches the network
if (!(await trigger(fieldsOf(workspaceSchema)))) return false;
try {
if (await isSlugFree(getValues("slug"))) return true;
setError("slug", { message: "That URL is taken. Try another one." });
} catch {
setError("slug", { message: "We could not check this URL. Try again." });
}
return false;
});
return (
<div className="flex flex-col gap-4">
<Input
label="Workspace name"
placeholder="Carter Studio"
error={formState.errors.workspace?.message}
{...register("workspace")}
/>
<Input
label="Workspace URL"
prefix="acme.app/"
placeholder="carter-studio"
autoCapitalize="none"
spellCheck={false}
error={formState.errors.slug?.message}
{...register("slug")}
/>
</div>
);
}
function TeamStep() {
const { control, trigger } = useFormContext<Signup>();
useFormWizardValidation(() => trigger(fieldsOf(teamSchema)));
return (
<Controller
name="teamSize"
control={control}
render={({ field, fieldState }) => (
<RadioGroup
label="How many people will use it?"
variant="card"
options={TEAM_SIZES}
value={field.value}
onValueChange={field.onChange}
onBlur={field.onBlur}
ref={field.ref}
name={field.name}
error={fieldState.error?.message}
/>
)}
/>
);
}
function ReviewStep() {
const { control, trigger } = useFormContext<Signup>();
useFormWizardValidation(() => trigger(fieldsOf(reviewSchema)));
const values = useWatch({ control });
const team = TEAM_SIZES.find((option) => option.value === values.teamSize)?.label;
return (
<div className="flex flex-col gap-5">
<FormWizardSummary
sections={[
{
step: "account",
items: [
{ label: "Name", value: values.name },
{ label: "Email", value: values.email },
],
},
{
step: "workspace",
items: [
{ label: "Workspace", value: values.workspace },
{ label: "URL", value: `acme.app/${values.slug}` },
],
},
{ step: "team", items: [{ label: "Team size", value: team }] },
]}
/>
<Controller
name="terms"
control={control}
render={({ field, fieldState }) => (
<Checkbox
label="I agree to the terms of service"
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
onBlur={field.onBlur}
ref={field.ref}
error={fieldState.error?.message}
/>
)}
/>
</div>
);
}
const STEPS: WizardStep[] = [
{ id: "account", title: "Account", description: "Who is setting this up?", content: <AccountStep /> },
{ id: "workspace", title: "Workspace", description: "Pick a name and a URL.", content: <WorkspaceStep /> },
{ id: "team", title: "Team", description: "We size the defaults for you.", content: <TeamStep /> },
{ id: "review", title: "Review", description: "Check everything, then create it.", content: <ReviewStep /> },
];
// ---------- saved progress ----------
const DRAFT_KEY = "signup-draft";
type Draft = { step: string; values: Partial<Signup> };
function readDraft(): Draft | null {
try {
const raw = localStorage.getItem(DRAFT_KEY);
return raw ? (JSON.parse(raw) as Draft) : null;
} catch {
return null;
}
}
function writeDraft(draft: Draft) {
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
} catch {
// private mode or a full disk: the form still works, it just forgets
}
}
function clearDraft() {
try {
localStorage.removeItem(DRAFT_KEY);
} catch {}
}
async function createWorkspace(values: z.output<typeof signupSchema>) {
const response = await fetch("/api/workspaces", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!response.ok) throw new Error(`Signup failed: ${response.status}`);
}
function SignupForm({ draft }: { draft: Draft | null }) {
const form = useZodForm(signupSchema, { defaultValues: { ...EMPTY, ...draft?.values } });
const [step, setStep] = useState(draft?.step ?? "account");
const [created, setCreated] = useState(false);
// save the step and every change; the step is part of the draft
useEffect(() => {
writeDraft({ step, values: form.getValues() });
const subscription = form.watch((values) => writeDraft({ step, values: values as Partial<Signup> }));
return () => subscription.unsubscribe();
}, [form, step]);
if (created) return <p role="status">Your workspace is ready.</p>;
return (
<FormProvider {...form}>
<FormWizard
steps={STEPS}
value={step}
onValueChange={setStep}
labels={{ submit: "Create workspace", done: "Created" }}
onComplete={async () => {
// throws on a stale or edited draft; the wizard shakes the button
const values = signupSchema.parse(form.getValues());
await createWorkspace(values);
clearDraft();
setCreated(true);
}}
className="h-dvh md:h-auto"
/>
</FormProvider>
);
}
export function SignupWizard() {
// undefined until the browser has read the draft, so server and client HTML match
const [draft, setDraft] = useState<Draft | null>();
useEffect(() => setDraft(readDraft()), []);
if (draft === undefined) return <div className="min-h-[480px]" />;
return <SignupForm draft={draft} />;
}

Two endpoints are yours to write: GET /api/workspaces/available and POST /api/workspaces. Parse the body with the same signupSchema on the server, so the client rules and the server rules cannot drift apart. When onComplete rejects, the wizard shakes the submit button and stays on the review step, and the draft is still saved.

The sign-up rules from form-validation also run on their own, outside a wizard. Leave a field, fix it, then submit with everything empty to see the timing useZodForm uses:

Leave the email field with a typo: the error appears on blur and clears as you fix it. Submit empty and focus jumps to the first error
$ npx wingo-ui@latest add form-validation
ProForm validation docs

Can I build a shadcn multi step form instead?

Yes. For a survey or an intake flow with one question per screen, use shadcn/ui's Questionnaire (opens in a new tab), added in August 2026 (opens in a new tab). It handles the active item, progress, Previous and Next, required answers and focus, and reads the answers from FormData. Its docs leave persistence and branching to your page and do not cover react-hook-form.

For steps that each hold several fields, like the signup here, the shadcn/ui component list (opens in a new tab) has Field, Progress and Button but no stepper or wizard as of October 2026. That shadcn multi step form keeps the step index in state, renders one group of fields per step, calls trigger() on Next exactly as above, and adds its own focus handling, announcements and transitions. Community registries publish stepper components that install with the shadcn CLI, such as ReUI's stepper (opens in a new tab), if you want the visuals without writing them.

The schema split, the trigger() per step, the async check and the draft code in this post work the same in that setup, since none of it depends on the wizard component.

Where should you start?

Start from the Form Wizard docs, which have the playground and examples for a vertical stepper, dots for onboarding, an async check and a custom footer with Save draft. The Form Wizard and the form validation helpers are part of Wingo UI Pro, which costs $8 a month, $80 a year or $150 once as of October 2026 (pricing); Input, Radio Group, Checkbox and Field are free. For every other field in the form, read the React form components guide, and browse the rest of the component guides. For a checkout built on the same one-step-at-a-time validation, see the React checkout page tutorial.

Components in this post

  • Form Wizard

    A React multi-step form wizard with a stepper, sync or async validation per step, a summary step and a full-screen phone layout.

    Pro
  • Steps

    A React stepper for checkouts, onboarding and KYC: circles, bars or dots, with complete, error and loading states and a phone header.

    Pro
  • Field

    A React form field wrapper every control shares: label, hint, animated error and counter, fieldsets, and the box recipe all inputs use.

    Free
  • Form validation

    Zod validation rules for React forms with one message set (English or Romanian), react-hook-form glue and a pretend request.

    Pro

FAQ

How do I validate only the current step in react-hook-form?

Call trigger() with an array of the current step's field names before you move on, and only advance when it resolves to true. With a zod resolver the whole schema still runs, but react-hook-form only reports errors for the fields you passed.

Should a multi step form use one useForm or one per step?

One useForm for the whole form is simpler: values survive when a step unmounts, the review step can read everything with useWatch, and the final submit gets one object. react-hook-form keeps the values of unmounted fields by default, since shouldUnregister is false.

How do I save multi step form progress across a page reload?

Subscribe to form changes with watch(callback), write the values and the current step id to localStorage, and read them back after the component mounts so the server HTML still matches. Never store passwords or card numbers, and clear the draft after a successful submit.

How do I add a progress bar to a React multi step form?

Show the current position as a progressbar with aria-valuenow and a visible Step 2 of 4 label. In the Wingo UI Form Wizard, stepper="progress" renders that header, and on phones every stepper turns into it automatically.

Does shadcn/ui have a multi step form component?

Partly. Since August 2026 shadcn/ui ships Questionnaire, which shows one question at a time with progress, Previous and Next, and required checks. It has no stepper or wizard for steps that each hold several fields, so that kind of shadcn multi step form still needs your own step state or a community registry.

  • Forms
  • React Hook Form
  • Zod
  • Wizard
  • Mobile UI

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Component guides

React Phone Input With Country Code and E.164 Value

A React phone input that formats per country as you type, picks the country from a pasted +code, validates with libphonenumber-js and returns one E.164 value.

SRSerban Rusu·Oct 13, 2026·8 min read
Component guides

React Form Components: Accessible Fields, Zod and Mobile

React form components that share one anatomy (label, hint, error, counter), plug into react-hook-form and zod, and behave right on a phone, with full code.

SRSerban Rusu·Oct 9, 2026·25 min read
Component guides

Romanian CUI Validation in TypeScript, Zod and React

Romanian CUI validation in TypeScript: the 753217532 check digit step by step, the RO prefix, a zod rule, a React field and a company lookup at ANAF.

SRSerban Rusu·Oct 9, 2026·11 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI