Multi-step form
A three-step form with a progress indicator, per-step validation and a review screen.
<form class="wizard" id="wizard" novalidate>
<ol class="steps" id="steps">
<li class="on" aria-current="step"><span>1</span>Details</li>
<li><span>2</span>Delivery</li>
<li><span>3</span>Review</li>
</ol>
<div class="bar"><i id="bar" style="width:33.3%"></i></div>
<!-- step 1 -->
<section class="step on" data-step="1" aria-label="Step 1 of 3, your details">
<h2>Your details</h2>
<div class="field">
<label for="fname">Full name</label>
<input type="text" id="fname" name="fname" autocomplete="name" required>
<p class="err" hidden>Please enter your name.</p>
</div>
<div class="field">
<label for="mail">Email address</label>
<input type="email" id="mail" name="mail" autocomplete="email" required>
<p class="err" hidden>Please enter a valid email address.</p>
</div>
</section>
<!-- step 2 -->
<section class="step" data-step="2" hidden aria-label="Step 2 of 3, delivery">
<h2>Delivery</h2>
<div class="field">
<label for="addr">Street address</label>
<input type="text" id="addr" name="addr" autocomplete="street-address" required>
<p class="err" hidden>Please enter an address.</p>
</div>
<div class="two">
<div class="field">
<label for="city">City</label>
<input type="text" id="city" name="city" autocomplete="address-level2" required>
<p class="err" hidden>Please enter a city.</p>
</div>
<div class="field">
<label for="post">Postcode</label>
<input type="text" id="post" name="post" autocomplete="postal-code" required>
<p class="err" hidden>Please enter a postcode.</p>
</div>
</div>
</section>
<!-- step 3 -->
<section class="step" data-step="3" hidden aria-label="Step 3 of 3, review">
<h2>Check and confirm</h2>
<dl class="review" id="review"></dl>
<label class="check">
<input type="checkbox" id="terms" required>
<span>I agree to the terms of sale.</span>
</label>
<p class="err" id="terms-err" hidden>Please agree to the terms before confirming.</p>
</section>
<div class="nav">
<button type="button" class="ghost" id="back" hidden>Back</button>
<button type="button" class="primary" id="next">Continue</button>
<button type="submit" class="primary" id="done" hidden>Place order</button>
</div>
<p class="status" id="status" role="status"></p>
</form>* { box-sizing: border-box; margin: 0; }
body { font: 16px/1.6 system-ui, -apple-system, sans-serif; background: #f6f8fb; padding: 24px; }
.wizard {
max-width: 520px;
margin: 0 auto;
padding: 26px 28px 28px;
background: #fff;
border: 1px solid #e2e7ef;
border-radius: 14px;
}
/* ---- progress ---- */
.steps {
display: flex;
gap: 8px;
list-style: none;
margin: 0 0 12px;
padding: 0;
font-size: .82rem;
color: #a9b4c5;
}
.steps li { display: flex; align-items: center; gap: 7px; flex: 1; }
.steps span {
width: 24px; height: 24px;
display: grid;
place-items: center;
border-radius: 50%;
background: #eef1f6;
color: #8593ab;
font-size: .78rem;
font-weight: 600;
flex-shrink: 0;
}
.steps li.on { color: #16202e; font-weight: 500; }
.steps li.on span { background: #3b4fe4; color: #fff; }
.steps li.past span { background: #1f8a5b; color: #fff; }
.bar { height: 4px; border-radius: 4px; background: #eef1f6; overflow: hidden; margin-bottom: 24px; }
.bar i { display: block; height: 100%; background: #3b4fe4; transition: width .25s ease; }
/* ---- fields ---- */
.step h2 { font-size: 1.15rem; color: #16202e; margin-bottom: 18px; }
.field { margin-bottom: 16px; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
label { display: block; font-size: .87rem; font-weight: 500; color: #16202e; margin-bottom: 6px; }
input[type="text"], input[type="email"] {
width: 100%;
font: inherit;
font-size: .95rem;
padding: 10px 12px;
border: 1px solid #d9e0ea;
border-radius: 8px;
color: #16202e;
}
input:focus { outline: 2px solid #3b4fe4; outline-offset: 1px; border-color: #3b4fe4; }
input[aria-invalid="true"] { border-color: #c93b3b; }
.err { font-size: .81rem; color: #c93b3b; margin-top: 5px; }
.check { display: flex; gap: 10px; align-items: flex-start; font-weight: 400; font-size: .88rem; color: #5b6b83; cursor: pointer; }
.check input { width: 16px; height: 16px; margin-top: 3px; accent-color: #3b4fe4; flex-shrink: 0; }
/* ---- review ---- */
.review { margin: 0 0 20px; display: grid; grid-template-columns: auto 1fr; gap: 8px 18px; font-size: .9rem; }
.review dt { color: #8593ab; }
.review dd { margin: 0; color: #16202e; }
/* ---- nav ---- */
.nav { display: flex; gap: 10px; margin-top: 24px; }
.nav button {
font: inherit;
font-size: .93rem;
font-weight: 500;
padding: 11px 22px;
border-radius: 9px;
cursor: pointer;
}
.primary { border: 0; background: #3b4fe4; color: #fff; margin-left: auto; }
.primary:hover { background: #2a3abf; }
.ghost { border: 1px solid #d9e0ea; background: #fff; color: #16202e; }
.ghost:hover { background: #f6f8fb; }
.status { margin-top: 14px; font-size: .9rem; text-align: center; color: #1f8a5b; }
@media (prefers-reduced-motion: reduce) { .bar i { transition: none; } }const form = document.getElementById("wizard");
const steps = [...form.querySelectorAll(".step")];
const crumbs = [...document.querySelectorAll(".steps li")];
const bar = document.getElementById("bar");
const back = document.getElementById("back");
const next = document.getElementById("next");
const done = document.getElementById("done");
let at = 0;
const isValid = (el) =>
el.type === "email"
? /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(el.value.trim())
: el.value.trim() !== "";
function checkStep(i) {
let ok = true, first = null;
steps[i].querySelectorAll("input[required]").forEach((el) => {
const bad = !isValid(el);
el.setAttribute("aria-invalid", bad ? "true" : "false");
const msg = el.parentElement.querySelector(".err");
if (msg) msg.hidden = !bad;
if (bad) { ok = false; if (!first) first = el; }
});
if (first) first.focus();
return ok;
}
function fillReview() {
const rows = [
["Name", form.fname.value],
["Email", form.mail.value],
["Address", form.addr.value],
["City", form.city.value],
["Postcode", form.post.value],
];
document.getElementById("review").innerHTML = rows
.map(([k, v]) => `<dt>${k}</dt><dd>${v || "—"}</dd>`)
.join("");
}
function show(i) {
steps.forEach((s, n) => (s.hidden = n !== i));
crumbs.forEach((c, n) => {
c.classList.toggle("on", n === i);
c.classList.toggle("past", n < i);
if (n === i) c.setAttribute("aria-current", "step");
else c.removeAttribute("aria-current");
});
bar.style.width = ((i + 1) / steps.length) * 100 + "%";
back.hidden = i === 0;
next.hidden = i === steps.length - 1;
done.hidden = i !== steps.length - 1;
// move focus to the new step's heading so the change is announced
const h = steps[i].querySelector("h2");
h.setAttribute("tabindex", "-1");
h.focus();
}
next.addEventListener("click", () => {
if (!checkStep(at)) return;
at++;
if (at === steps.length - 1) fillReview();
show(at);
});
back.addEventListener("click", () => { at--; show(at); });
form.addEventListener("submit", (e) => {
e.preventDefault();
const terms = document.getElementById("terms");
const err = document.getElementById("terms-err");
if (!terms.checked) { err.hidden = false; terms.focus(); return; }
err.hidden = true;
document.getElementById("status").textContent = "Order placed. A confirmation is on its way.";
done.disabled = true;
});How it works
One form element, several sections. Not three separate forms. That keeps all the values in one place, lets the browser autofill across steps, and means the final submit carries everything without any state juggling.
Validate per step, not at the end. Nobody should reach step three and discover a problem on step one. Each Continue checks only its own step, and focus moves to the first field that failed.
Focus moves to the new step's heading. Without this, activating Continue leaves focus on a button that has just been replaced by different content, and a screen reader user hears nothing. The heading takes tabindex="-1" so it can be focused programmatically without joining the tab order.
The review step is not decoration. Showing people what they typed before they commit reduces support tickets more than any amount of inline validation. It costs one loop over the values.
The progress bar and the step list say the same thing twice — deliberately. The bar is a quick visual read; the numbered list names each stage so people know what is still to come.
Accessibility notes
Each step is a <section> with an aria-label naming its position: "Step 2 of 3, delivery". Hidden steps use the hidden attribute, which removes them from the accessibility tree entirely rather than merely hiding them visually.
aria-current="step" marks the active item in the progress list, and moves with it.
The review list is a real <dl>, so labels and values are programmatically paired rather than being two columns that only look related.
Making it yours
Three steps is about the limit before a single well-organised page is better. The evidence on multi-step forms is mixed: they raise completion when the alternative is an intimidating wall of forty fields, and lower it when they split six fields into three screens.
To persist progress across a reload, write the values to sessionStorage on each step change and restore them on load. Never put payment details anywhere near browser storage.
The autocomplete tokens here — street-address, address-level2, postal-code — are the standard ones. Browsers fill a whole address in one tap when you use them, which matters more on mobile than anything else on this page.
Related templates
Contact form
A contact form with real labels, inline validation and error and success states.
HTMLCSSJavaScriptLogin form
A sign-in card with a show-password toggle and a single generic error.
HTMLCSSJavaScriptNewsletter signup
An inline email capture with validation and a success state, in three layouts.
HTMLCSSJavaScriptToggle switch
A switch built on a real checkbox, with sizes, labels and a disabled state.
HTMLCSSCheck your version
Once you have edited this, the HTML validator will catch any tag you left unclosed, and the formatter will tidy the indentation. Both run in your browser.