Multi-Step Signup Form with Step Indicator

A signup wizard in a single card. A three-step indicator across the top shows finished steps with a tick, the current step with an orange outline and later steps in grey. Below it the form changes with each step: account details, workspace setup with pill-shaped team size options, then a choice of plan. Back and Continue move between steps and keep what was typed. The last step leads to a short confirmation. The preview opens on step two so all three indicator states are visible. On phones the team size pills wrap into two rows.

Code

import { useState } from "react";

const steps = [
  { title: "Account", heading: "Create your account", text: "Use your work email so teammates can find you." },
  { title: "Workspace", heading: "Set up your workspace", text: "This is where your projects and people will live." },
  { title: "Plan", heading: "Pick a starting plan", text: "You can switch plans whenever you like." },
];

const teamSizes = ["Just me", "2 to 10", "11 to 50", "Over 50"];

const plans = [
  { id: "free", name: "Free", price: "$0", note: "3 projects, 2 members" },
  { id: "team", name: "Team", price: "$18", note: "Unlimited projects, 15 members" },
  { id: "studio", name: "Studio", price: "$42", note: "Client access and priority help" },
];

const initial = { name: "Noor Haddad", email: "[email protected]", password: "", workspace: "Brightloom Studio", size: "2 to 10", role: "Design", plan: "team" };

const field = "mt-2 block h-11 w-full rounded-full border border-stone-300 bg-white px-4 text-sm text-stone-900 placeholder:text-stone-400 focus:border-stone-900 focus:outline-none focus:ring-2 focus:ring-orange-500/40";
const ring = "focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-500 focus-visible:ring-offset-2";

export default function MultiStepSignupFormWithStepIndicator() {
  const [step, setStep] = useState(1);
  const [done, setDone] = useState(false);
  const [form, setForm] = useState(initial);
  const set = (key: keyof typeof initial, value: string) => setForm({ ...form, [key]: value });
  const last = step === steps.length - 1;

  return (
    <section className="bg-orange-50 px-4 py-12 text-stone-900 sm:py-20">
      <div className="mx-auto max-w-xl rounded-3xl bg-white p-6 shadow-sm ring-1 ring-orange-100 sm:p-10">
        <ol className="flex items-start">
          {steps.map((s, i) => {
            const complete = done || i < step;
            const current = !done && i === step;
            return (
              <li key={s.title} aria-current={current ? "step" : undefined} className="relative flex flex-1 flex-col items-center">
                {i > 0 && <span aria-hidden="true" className={`absolute right-1/2 top-[18px] mr-[18px] h-0.5 w-[calc(100%-36px)] -translate-y-1/2 ${done || i <= step ? "bg-orange-500" : "bg-stone-200"}`} />}
                <span className={`relative flex h-9 w-9 items-center justify-center rounded-full text-sm font-semibold ${complete ? "bg-orange-500 text-white" : current ? "bg-white text-stone-900 ring-2 ring-inset ring-orange-500" : "bg-white text-stone-600 ring-2 ring-inset ring-stone-200"}`}>
                  {complete ? (
                    <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4">
                      <path d="m5 10.5 3.5 3.5L15 7" />
                    </svg>
                  ) : (
                    i + 1
                  )}
                </span>
                <span className={`mt-2 text-xs font-medium ${current || complete ? "text-stone-900" : "text-stone-600"}`}>
                  {s.title}
                  {complete && <span className="sr-only"> (done)</span>}
                </span>
              </li>
            );
          })}
        </ol>

        {done ? (
          <div className="mt-10 text-center">
            <h2 className="text-2xl font-semibold tracking-tight">You are all set, {form.name.split(" ")[0]}</h2>
            <p className="mx-auto mt-3 max-w-sm text-sm leading-relaxed text-stone-600">
              {form.workspace} is ready on the {plans.find((p) => p.id === form.plan)?.name} plan. We sent a confirmation link to {form.email}.
            </p>
            <button type="button" onClick={() => { setDone(false); setStep(0); }} className={`mt-8 inline-flex h-11 items-center rounded-full border border-stone-300 px-6 text-sm font-semibold transition hover:bg-stone-50 ${ring}`}>
              Start again
            </button>
          </div>
        ) : (
          <form
            action="#"
            onSubmit={(e) => {
              e.preventDefault();
              if (last) setDone(true);
              else setStep(step + 1);
            }}
            className="mt-10"
          >
            <p className="text-xs font-semibold uppercase tracking-widest text-orange-700">Step {step + 1} of {steps.length}</p>
            <h2 className="mt-2 text-2xl font-semibold tracking-tight">{steps[step].heading}</h2>
            <p className="mt-2 text-sm text-stone-600">{steps[step].text}</p>

            {step === 0 && (
              <div className="mt-7 space-y-5">
                <div>
                  <label htmlFor="signup-name" className="text-sm font-medium">Full name</label>
                  <input id="signup-name" type="text" required autoComplete="name" value={form.name} onChange={(e) => set("name", e.target.value)} className={field} />
                </div>
                <div>
                  <label htmlFor="signup-email" className="text-sm font-medium">Work email</label>
                  <input id="signup-email" type="email" required autoComplete="email" value={form.email} onChange={(e) => set("email", e.target.value)} className={field} />
                </div>
                <div>
                  <label htmlFor="signup-password" className="text-sm font-medium">Password</label>
                  <input id="signup-password" type="password" autoComplete="new-password" placeholder="At least 10 characters" value={form.password} onChange={(e) => set("password", e.target.value)} className={field} />
                </div>
              </div>
            )}

            {step === 1 && (
              <div className="mt-7 space-y-6">
                <div>
                  <label htmlFor="signup-workspace" className="text-sm font-medium">Workspace name</label>
                  <input id="signup-workspace" type="text" required value={form.workspace} onChange={(e) => set("workspace", e.target.value)} className={field} />
                </div>
                <fieldset>
                  <legend className="text-sm font-medium">How big is your team?</legend>
                  <div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
                    {teamSizes.map((size) => (
                      <label key={size} htmlFor={`signup-size-${size}`} className="relative cursor-pointer">
                        <input id={`signup-size-${size}`} type="radio" name="size" value={size} checked={form.size === size} onChange={() => set("size", size)} className="peer sr-only" />
                        <span className="flex h-11 items-center justify-center rounded-full border border-stone-300 text-sm font-medium text-stone-700 transition peer-checked:border-stone-900 peer-checked:bg-stone-900 peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-orange-500 peer-focus-visible:ring-offset-2">
                          {size}
                        </span>
                      </label>
                    ))}
                  </div>
                </fieldset>
                <div>
                  <label htmlFor="signup-role" className="text-sm font-medium">What do you mostly work on?</label>
                  <div className="relative">
                    <select id="signup-role" value={form.role} onChange={(e) => set("role", e.target.value)} className={`${field} appearance-none pr-10`}>
                      <option>Design</option>
                      <option>Engineering</option>
                      <option>Marketing</option>
                      <option>Operations</option>
                    </select>
                    <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="pointer-events-none absolute right-4 top-1/2 mt-1 h-4 w-4 -translate-y-1/2 text-stone-600">
                      <path d="m5 7.5 5 5 5-5" />
                    </svg>
                  </div>
                </div>
              </div>
            )}

            {step === 2 && (
              <fieldset className="mt-7">
                <legend className="sr-only">Plan</legend>
                <div className="space-y-3">
                  {plans.map((p) => (
                    <label key={p.id} htmlFor={`signup-plan-${p.id}`} className="relative block cursor-pointer">
                      <input id={`signup-plan-${p.id}`} type="radio" name="plan" value={p.id} checked={form.plan === p.id} onChange={() => set("plan", p.id)} className="peer sr-only" />
                      <span className="flex items-center gap-4 rounded-2xl border border-stone-300 px-5 py-4 transition peer-checked:border-stone-900 peer-checked:bg-orange-50 peer-focus-visible:ring-2 peer-focus-visible:ring-orange-500 peer-focus-visible:ring-offset-2">
                        <span className={`flex h-5 w-5 flex-none items-center justify-center rounded-full border-2 ${form.plan === p.id ? "border-stone-900" : "border-stone-300"}`}>
                          {form.plan === p.id && <span className="h-2.5 w-2.5 rounded-full bg-stone-900" />}
                        </span>
                        <span className="min-w-0 flex-1">
                          <span className="block text-sm font-semibold">{p.name}</span>
                          <span className="block text-sm text-stone-600">{p.note}</span>
                        </span>
                        <span className="flex-none text-right text-sm text-stone-600">
                          <span className="text-lg font-semibold text-stone-900">{p.price}</span> / mo
                        </span>
                      </span>
                    </label>
                  ))}
                </div>
              </fieldset>
            )}

            <div className="mt-8 flex items-center justify-between gap-3">
              <button type="button" disabled={step === 0} onClick={() => setStep(step - 1)} className={`inline-flex h-11 items-center rounded-full px-5 text-sm font-semibold text-stone-700 transition hover:bg-stone-100 disabled:invisible ${ring}`}>
                Back
              </button>
              <button type="submit" className={`inline-flex h-11 items-center gap-2 rounded-full bg-stone-900 px-6 text-sm font-semibold text-white transition hover:bg-stone-700 ${ring}`}>
                {last ? "Create account" : "Continue"}
                <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4">
                  <path d="M4 10h12m-4.5-4.5L16 10l-4.5 4.5" />
                </svg>
              </button>
            </div>
          </form>
        )}
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/multi-step-signup-form-with-step-indicator.json

When to use it

How to customize it

Accessibility

Questions

How does the step indicator know which step is active?

One `step` number in state drives everything. Steps with a lower index render as finished, the matching index renders as current and the rest as upcoming. The Continue and Back buttons add or subtract one.

Is the data kept when someone goes back a step?

Yes. All values live in a single `form` object in state, not in the fields themselves, so moving between steps never clears them.

How do I submit the form at the end?

In the `onSubmit` handler, the branch where `last` is true is the final submit. Send the `form` object to your server there, then call `setDone(true)` when it succeeds.

How many steps should a signup form have?

Two to four works well. Put the easiest questions first, keep each step to one topic and say how many steps there are, as the indicator and the Step 2 of 3 line do here.

Can people click a step in the indicator to jump to it?

Not as written. To allow it, wrap each circle in a `<button>` that calls `setStep(i)` and only enable it for steps already finished.

Related: Dark Sign-In Form with Show Password Toggle, Checkout Payment Form with Order Summary, Pricing Table with Monthly and Yearly Toggle, Button Set: Primary, Secondary, Ghost, Icon and Loading.

More forms or all free UI components. Start for free to have a full site built around it.

More in Forms

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap