Blog//7 min read
Multi step lead form in React with validation
Build a React multi step form with a step state machine, per step validation, option cards instead of selects, a progress bar and a thank you state.
MSMarco Schulz, founder of Facade UI
Five edge cases a quote form has to survive: the back button, validation on step two, the mobile keyboard covering the submit button, a page refresh and the success state. I learned each of them by shipping a form that failed at one. The Multi-step lead form in Facade is the version that survives all five, and this article explains how it is built so you can write your own or install it and understand what you are getting.
Why split the form at all
A single long form has one advantage: everything is visible. On a desktop that is fine. On a phone, a form with eight fields is a wall, and the field that matters most to the business, the phone number, is at the bottom.
Splitting the form into three screens changes the psychology. The first screen asks a question the visitor wants to answer: what kind of work do you need? That is not data entry, it is describing their problem. By the time contact details come up, they have invested two steps and the request feels half done. I do not have a study to cite for this; I have the pattern working on client sites and the inbox to show for it.
Three steps is the number I settled on. Service, Details, Contact. Two is a glorified single form, four starts to feel like a survey.
The step state machine
The form holds three pieces of state and derives everything else:
step, 0 to 2direction, 1 or -1, for the slide animationdata, a singleLeadFormDataobject every step writes into
export interface LeadFormData {
service: string;
timeline: string;
zip: string;
details: string;
name: string;
email: string;
phone: string;
}Keeping all fields in one object instead of one state per step means the back button is free. Going back is setStep(step - 1) and the previous screen renders from the same data it wrote. There is no "save draft of step 1" logic because there is nothing to save.
A done flag covers the success state. It is separate from step because the success screen is not a step; it has no validation and no back button, and the progress bar should read 100 percent.
Per step validation
Validation runs when the visitor tries to move forward, not on every keystroke. A function takes the step index and the data and returns an errors object keyed by field name:
type Errors = Partial<Record<keyof LeadFormData, string>>;
function validate(step: number, d: LeadFormData): Errors {
const e: Errors = {};
if (step === 0 && !d.service) e.service = "Choose the type of work you need.";
if (step === 1) {
if (!d.timeline) e.timeline = "Pick a timeline.";
if (!/^\d{5}$/.test(d.zip.trim())) e.zip = "Enter a 5 digit ZIP code.";
}
if (step === 2) {
if (d.name.trim().length < 2) e.name = "Enter your name.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(d.email.trim())) e.email = "Enter a valid email.";
if (d.phone.replace(/\D/g, "").length !== 10) e.phone = "Enter a 10 digit phone number.";
}
return e;
}If the object has keys, the form stays on the step, shows the messages and does not move. If it is empty, the step advances. When a field with an error changes, its error clears immediately, which is the one place where validation on input is the right call: the visitor is fixing the thing you just flagged, and the red text should go away as soon as they do.
Error messages render in a role="alert" region so screen readers announce them, and the step heading receives focus after each transition. That second detail is easy to skip and important: without it, a keyboard user who presses Continue is left with focus on a button that no longer exists.
The phone validation accepts any format and counts digits. Visitors type (512) 555-0142, 512.555.0142 and 5125550142, and all three are valid. On submit the component strips the phone to digits so your CRM gets one shape.
Option cards instead of selects
The first step is the one that sets the tone, and a <select> labeled "Service" sets the wrong one. The form uses option cards: a two column grid of buttons, each with an icon, a label and a one line description.
import { Wrench, Zap, Home, Sparkles } from "lucide-react";
import type { LeadOption } from "@/components/facade/multi-step-lead-form";
const options: LeadOption[] = [
{ value: "repair", label: "Repair", description: "Something is broken or leaking", icon: Wrench },
{ value: "install", label: "New install", description: "Replace or add equipment", icon: Zap },
{ value: "remodel", label: "Remodel", description: "Kitchen, bath or full room", icon: Home },
{ value: "maintenance", label: "Maintenance", description: "Tune-up or inspection", icon: Sparkles },
];Semantically the grid is a role="radiogroup" and each card a role="radio" with aria-checked. That gives screen readers the same model as native radio buttons while the visual is a tappable card. The selected card shows a check mark that animates in through AnimatePresence, and the cards shrink slightly on tap unless the visitor prefers reduced motion.
The timeline on step two uses the same radio pattern as a row of chips: "As soon as possible", "Within 2 weeks", "Within a month", "Just planning". Four chips tell the business how urgent the lead is before anyone picks up the phone.
The progress bar
Two indicators sit at the top: a numbered stepper with the step names and a thin bar. The stepper marks the current step with aria-current="step" and swaps completed numbers for a check icon. The bar is a role="progressbar" with aria-valuemin, aria-valuemax and aria-valuenow, and its width animates with motion as the visitor advances.
The width is derived, not stored: (step + 1) / (STEPS.length + 1), and 1 when done. Counting the success screen as the final segment means the bar never shows 100 percent while there is still a field to fill, which is a small honesty that visitors notice without knowing why.
Submit and thank you
The submit handler is async. It calls onSubmit with the collected data and waits for the returned promise before showing the success state, so a slow API keeps the button in its loading state instead of flashing a thank you before the lead is saved.
import { MultiStepLeadForm, type LeadFormData } from "@/components/facade/multi-step-lead-form";
<MultiStepLeadForm
title="Request a quote"
options={options}
timelines={["This week", "This month", "Next 3 months", "Just planning"]}
onSubmit={async (data: LeadFormData) => {
const res = await fetch("/api/leads", { method: "POST", body: JSON.stringify(data) });
if (!res.ok) throw new Error("Lead not saved");
}}
successTitle="Request received"
successMessage="A licensed electrician will call you within one business hour."
/>The success screen greets the visitor by first name, pulled from the name they just typed, and shows the message you pass. It slides in with the same direction aware transition as the steps, so the end of the form feels like the end of the same motion instead of a page change.
The form is a Pro component. It installs from the private registry once the license key is in components.json, a one time step covered in the installation docs:
npx shadcn@latest add @facade/multi-step-lead-formOn an electrician site it replaces the contact form entirely. "What will it cost?" is the question an electrical contractor's visitor asks, and a three step form that starts with "what kind of work" answers it better than a textarea ever did.
FAQ
Does the form survive a page refresh?
Not by default; state is in React. If a client needs it, persist data and step to sessionStorage in an effect and hydrate them on mount. I leave it out because most visitors finish the three steps in under a minute and a stale restored form is worse than an empty one.
Can I add a fourth step?
Yes, the source is yours after installation. Add the step name to the STEPS array, a branch in validate, and a screen in the render switch. The progress and transition logic reads the array length, so nothing else changes.
How do I send leads to a CRM?
onSubmit receives the full LeadFormData. Post it to a route handler that forwards to HubSpot, a Google Sheet, a webhook or an email. Returning the fetch promise keeps the loading state until your route responds.