Multi-Step Form Wizard

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.

Forms & InputsInspired by Stripe
Live PreviewInteractive
Stripe · Onboarding
1
Account
2
Details
3
Review
Step 1 of 3

Step 1 is pre-filled · Next is gated by per-step validation · Final step shows a review summary before submit

FormWizard.tsx
// 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