A 3-step onboarding wizard with a stepper indicator (active / completed / pending), per-step validation that gates Next, Back navigation, a final review step with a terms checkbox, and a success state on submit. Plain useState — no form library required. Inspired by Stripe.
Step 1 is pre-filled · Next is gated by per-step validation · Final step shows a review summary before submit
// Dependencies: react ^18, lucide-react
import React, { useState } from 'react';
import { Check, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
function cn(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(' ');
}
export type WizardData = {
email: string;
password: string;
fullName: string;
company: string;
role: string;
acceptTerms: boolean;
};
const EMPTY: WizardData = {
email: '', password: '', fullName: '', company: '', role: '', acceptTerms: false,
};
const ROLES = ['Founder', 'Engineer', 'Designer', 'Product', 'Other'];
const STEPS = [
{ key: 'account', label: 'Account' },
{ key: 'details', label: 'Details' },
{ key: 'review', label: 'Review' },
] as const;
type Props = {
initialData?: Partial<WizardData>;
onSubmit?: (data: WizardData) => void;
};
export function FormWizard({ initialData, onSubmit }: Props) {
const [step, setStep] = useState(0);
const [data, setData] = useState<WizardData>({ ...EMPTY, ...initialData });
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const update = <K extends keyof WizardData>(key: K, value: WizardData[K]) => {
setData((d) => ({ ...d, [key]: value }));
};
const isStepValid = (i: number): boolean => {
if (i === 0) return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email) && data.password.length >= 6;
if (i === 1) return data.fullName.trim().length > 0 && data.company.trim().length > 0 && data.role.length > 0;
if (i === 2) return data.acceptTerms;
return false;
};
const canAdvance = isStepValid(step);
const isLast = step === STEPS.length - 1;
const next = () => {
if (!canAdvance) return;
if (isLast) {
setSubmitting(true);
setTimeout(() => {
setSubmitting(false);
setSubmitted(true);
onSubmit?.(data);
}, 700);
return;
}
setStep((s) => s + 1);
};
const back = () => setStep((s) => Math.max(0, s - 1));
if (submitted) {
return (
<div className="w-full max-w-[440px] bg-zinc-900 border border-white/[0.08] rounded-xl p-8 flex flex-col items-center text-center">
<div className="w-12 h-12 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center mb-4">
<Check className="w-5 h-5 text-emerald-400" />
</div>
<h3 className="text-base font-semibold text-white mb-1">Account created</h3>
<p className="text-sm text-zinc-400">
Welcome aboard, {data.fullName || data.email}. Check your inbox to confirm.
</p>
</div>
);
}
return (
<div className="w-full max-w-[440px] bg-zinc-900 border border-white/[0.08] rounded-xl overflow-hidden">
<div className="px-5 pt-5 pb-4 border-b border-white/[0.06]">
<StepperBar current={step} />
</div>
<div key={step} className="px-5 py-5 min-h-[260px]">
{step === 0 && <StepAccount data={data} update={update} />}
{step === 1 && <StepDetails data={data} update={update} />}
{step === 2 && <StepReview data={data} update={update} />}
</div>
<div className="flex items-center justify-between px-5 py-3 border-t border-white/[0.06] bg-black/20">
<button
onClick={back}
disabled={step === 0}
className={cn(
'flex items-center gap-1 px-2.5 py-1.5 rounded-md text-sm font-medium transition-colors',
step === 0 ? 'text-zinc-700 cursor-not-allowed' : 'text-zinc-300 hover:bg-white/[0.05]'
)}
>
<ChevronLeft className="w-3.5 h-3.5" />
Back
</button>
<span className="text-[11px] text-zinc-600">
Step {step + 1} of {STEPS.length}
</span>
<button
onClick={next}
disabled={!canAdvance || submitting}
className={cn(
'flex items-center gap-1 px-3.5 py-1.5 rounded-md text-sm font-semibold transition-colors active:scale-[0.97]',
canAdvance && !submitting
? 'bg-indigo-500 hover:bg-indigo-400 text-white'
: 'bg-zinc-800 text-zinc-500 cursor-not-allowed'
)}
>
{submitting ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Creating...
</>
) : isLast ? (
'Create account'
) : (
<>
Next
<ChevronRight className="w-3.5 h-3.5" />
</>
)}
</button>
</div>
</div>
);
}
function StepperBar({ current }: { current: number }) {
return (
<div className="flex items-center justify-between">
{STEPS.map((s, i) => {
const completed = i < current;
const active = i === current;
return (
<div key={s.key} className="flex items-center gap-2 flex-1">
<div
className={cn(
'w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-semibold shrink-0 transition-colors',
completed
? 'bg-emerald-500/15 text-emerald-300 border border-emerald-500/30'
: active
? 'bg-indigo-500/15 text-indigo-300 border border-indigo-500/40'
: 'bg-zinc-800 text-zinc-500 border border-zinc-700'
)}
>
{completed ? <Check className="w-3 h-3" /> : i + 1}
</div>
<span
className={cn(
'text-xs font-medium truncate',
completed ? 'text-zinc-400' : active ? 'text-white' : 'text-zinc-600'
)}
>
{s.label}
</span>
{i < STEPS.length - 1 && (
<div
className={cn(
'h-px flex-1 mx-2 transition-colors',
completed ? 'bg-emerald-500/30' : 'bg-white/[0.06]'
)}
/>
)}
</div>
);
})}
</div>
);
}
function Field({ label, children, hint }: { label: string; children: React.ReactNode; hint?: string }) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-medium text-zinc-400">{label}</span>
{children}
{hint && <span className="text-[11px] text-zinc-600">{hint}</span>}
</label>
);
}
const inputCls =
'w-full bg-zinc-800/60 border border-white/[0.08] rounded-md px-3 py-2 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-indigo-500/50 transition-colors';
function StepAccount({ data, update }: { data: WizardData; update: <K extends keyof WizardData>(k: K, v: WizardData[K]) => void }) {
return (
<div className="flex flex-col gap-4">
<Field label="Email" hint="We'll send a verification link.">
<input type="email" value={data.email} onChange={(e) => update('email', e.target.value)} placeholder="[email protected]" className={inputCls} />
</Field>
<Field label="Password" hint="Minimum 6 characters.">
<input type="password" value={data.password} onChange={(e) => update('password', e.target.value)} placeholder="••••••••" className={inputCls} />
</Field>
</div>
);
}
function StepDetails({ data, update }: { data: WizardData; update: <K extends keyof WizardData>(k: K, v: WizardData[K]) => void }) {
return (
<div className="flex flex-col gap-4">
<Field label="Full name">
<input type="text" value={data.fullName} onChange={(e) => update('fullName', e.target.value)} placeholder="Sarah Chen" className={inputCls} />
</Field>
<Field label="Company">
<input type="text" value={data.company} onChange={(e) => update('company', e.target.value)} placeholder="Acme Corp" className={inputCls} />
</Field>
<Field label="Your role">
<div className="flex flex-wrap gap-1.5">
{ROLES.map((r) => {
const active = data.role === r;
return (
<button
key={r}
type="button"
onClick={() => update('role', r)}
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium border transition-colors',
active
? 'bg-indigo-500/15 border-indigo-500/40 text-indigo-200'
: 'bg-zinc-800/60 border-white/[0.08] text-zinc-400 hover:text-zinc-200'
)}
>
{r}
</button>
);
})}
</div>
</Field>
</div>
);
}
function StepReview({ data, update }: { data: WizardData; update: <K extends keyof WizardData>(k: K, v: WizardData[K]) => void }) {
return (
<div className="flex flex-col gap-4">
<div className="bg-zinc-800/40 border border-white/[0.06] rounded-lg divide-y divide-white/[0.06]">
<ReviewRow label="Email" value={data.email} />
<ReviewRow label="Full name" value={data.fullName} />
<ReviewRow label="Company" value={data.company} />
<ReviewRow label="Role" value={data.role} />
</div>
<label className="flex items-start gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={data.acceptTerms}
onChange={(e) => update('acceptTerms', e.target.checked)}
className="mt-0.5 w-3.5 h-3.5 rounded border-white/[0.15] bg-zinc-800 accent-indigo-500"
/>
<span className="text-[12px] text-zinc-400 leading-relaxed">
I agree to the Terms of Service and Privacy Policy.
</span>
</label>
</div>
);
}
function ReviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-3 px-3 py-2">
<span className="text-[11px] text-zinc-500">{label}</span>
<span className="text-[13px] text-white truncate">{value || '—'}</span>
</div>
);
}Unlock to copy
Free access to all patterns
Command Palette
Linear
Global Search with Results Grouping
Linear
Recent + Suggested Search
Raycast
Filter Builder (AND / OR)
Linear
Sidebar Navigation
Linear
Collapsible Nested Tree Nav
Notion
Tab Bar with Overflow Menu
Vercel
Breadcrumb with Dropdowns
Linear
Workspace Switcher
Slack
Deployment Status Card
Vercel
Empty State
Vercel
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Single Big Metric Card
Stripe
Confirmation Dialog
Linear
Slide-Over Panel
Stripe
Toast / Snackbar Stack
Vercel
Issue / Task Card
Linear
Activity Feed
Linear + Slack
Properties Panel
Notion + Linear
Multi-Step Form Wizard
Stripe
Inline Data Table
Notion + Linear