Checkout flows, onboarding, that "set up your account in 4 easy steps" thing — they all use the same widget: a stepper. Numbered circles across the top, a Next button, and the vague promise that you're almost done. I always assumed there was some clever library doing the heavy lifting. There isn't. A stepper is one of the smallest, most honest components you can build, and writing one from scratch finally made the pattern click for me.
Here's the whole idea: a stepper is a finite-state machine. Four values run the entire thing.
const STEPS = ["account", "profile", "payment", "review"];
let current = 0; // which step is on screen
const done = [false, false, false, false]; // which steps have passed validation
const data = {}; // every field, all in one place
That's the model. Everything you see — the circles, the connector bars, the progress percentage, the buttons — is a function of current and done. Change the state, re-render, done. No scattered "is this circle green" flags to fall out of sync.
Three states per step
At any moment every step is exactly one of three things, and the rule is three lines:
function stepState(i){
if (i === current) return "active";
if (done[i]) return "done"; // already validated
return "upcoming";
}
Active is the step you're on. Done gets a checkmark and a filled bar behind it. Upcoming is greyed out. The connector bar before step i fills when step i-1 is done. That's the entire visual header, derived from two variables.
Next is a gate, not a button
This is the part that makes it a wizard and not just tabs. Next doesn't navigate — it asks permission first. Each step owns a validator that takes the field values and hands back a map of error messages, where an empty string means "this one's fine":
const RULES = {
0: v => ({
"f-email": /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v["f-email"]) ? "" : "Enter a valid email.",
"f-password": (v["f-password"] || "").length >= 8 ? "" : "At least 8 characters."
}),
// profile, payment...
};
function next(){
const errs = RULES[current](readFields(current));
const ok = Object.values(errs).every(m => m === "");
paintErrors(errs); // show inline messages
done[current] = ok;
if (ok && current < STEPS.length - 1) current++; // only advance when clean
render();
}
If anything's invalid, you paint the errors and just... don't move. The index stays put. That guarantees a nice invariant: you can never be standing on a step whose earlier steps hold garbage. Keeping the validator pure (values in, errors out, no DOM poking) also means you can test it in isolation and reuse it for a final check before submit.
Linear vs non-linear
Two navigation styles, one predicate. In a linear wizard the circles are decoration — the only way forward is Next. In a non-linear one, you can click any completed step to jump straight back and edit it. Spot a typo on the review screen? Click the Account circle instead of hitting Back three times.
function canJump(i){
if (i === current) return false;
return nonLinear && done[i]; // completed steps only, non-linear only
}
Notice what doesn't change: you still can't jump to an un-validated future step. Non-linear loosens where you can go, not whether validation runs.
The bit everyone gets wrong: persistence
Walking back to step one must not wipe what you typed in step two. The trap is rebuilding a step's markup every time you navigate — it looks clean and it throws away everything the user entered. Don't. Keep all the panels in the DOM and just toggle visibility:
document.querySelectorAll(".wizstep").forEach(p =>
p.classList.toggle("hidden", +p.dataset.step !== current));
Hidden inputs keep their values for free. Back and Next cost nothing. Then mirror the validated fields into that shared data object so you've got a clean source of truth to render the review screen from and to submit.
The stuff that separates a demo from something you'd ship
-
Accessibility. Mark the current circle with
aria-current="step". On a blocked Next, setaria-invalidon the bad fields and move focus to the first one so the error is announced and reachable. If circles are clickable, make them real buttons (Enter/Space, focus ring). Never rely on colour alone — the checkmark and label carry the state too. - Live error recovery. Clear a field's error the moment the user starts fixing it, not on the next click. Instant feedback feels responsive.
- Boundaries. Disable Back on the first step; swap Next for Submit on the last.
- The review step. A read-only summary before anything irreversible. Mask the card number, never echo the password. It's the cheap moment to catch a transposed digit.
None of this needs a framework. It's a current index, a done flag per step, a validator that gates forward motion, and a rule for which steps you're allowed to reach. Model that state cleanly and the UI is just a projection of it.
I built the full interactive version — 4-step signup, real validation gating, non-linear jumps, the works — here: https://dev48v.infy.uk/design/day33-stepper.html
Top comments (0)