Multi-step form wizard
A progressive form split into steps with native validation per step, a progress strip, back and next controls and a review screen.
Tailwind CSSFormswizardmulti-stepvalidationform
Live preview
Code
<form id="wizard" novalidate class="mx-auto max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<ol id="steps" class="mb-6 flex gap-2" aria-label="Progress">
<li class="h-1.5 flex-1 rounded-full bg-indigo-600"></li>
<li class="h-1.5 flex-1 rounded-full bg-slate-200"></li>
<li class="h-1.5 flex-1 rounded-full bg-slate-200"></li>
</ol>
<fieldset data-step class="space-y-3">
<legend class="mb-2 text-lg font-semibold text-slate-900">Your details</legend>
<label class="block text-sm text-slate-700">Full name
<input name="name" required class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 invalid:border-rose-400 [&:user-invalid]:border-rose-500"></label>
<label class="block text-sm text-slate-700">Company
<input name="company" required class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"></label>
</fieldset>
<fieldset data-step hidden class="space-y-3">
<legend class="mb-2 text-lg font-semibold text-slate-900">Your needs</legend>
<label class="block text-sm text-slate-700">Team size
<select name="size" required class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2">
<option value="">Choose</option><option>1 to 10</option><option>11 to 50</option><option>51 or more</option></select></label>
</fieldset>
<fieldset data-step hidden class="space-y-3">
<legend class="mb-2 text-lg font-semibold text-slate-900">Review</legend>
<dl id="summary" class="space-y-1 text-sm text-slate-700"></dl>
</fieldset>
<div class="mt-6 flex justify-between">
<button type="button" id="back" class="rounded-lg px-4 py-2 text-sm text-slate-600 hover:bg-slate-100" disabled>Back</button>
<button type="button" id="next" class="rounded-lg bg-indigo-600 px-5 py-2 text-sm font-medium text-white hover:bg-indigo-500">Next</button>
</div>
</form>
const form = document.getElementById('wizard');
const panes = [...form.querySelectorAll('[data-step]')];
const bars = [...document.getElementById('steps').children];
const back = document.getElementById('back');
const next = document.getElementById('next');
let current = 0;
function show(i) {
current = i;
panes.forEach((p, n) => { p.hidden = n !== i; });
bars.forEach((b, n) => b.classList.toggle('bg-indigo-600', n <= i));
bars.forEach((b, n) => b.classList.toggle('bg-slate-200', n > i));
back.disabled = i === 0;
next.textContent = i === panes.length - 1 ? 'Submit' : 'Next';
if (i === panes.length - 1) {
// Build the summary with textContent so user input is never parsed as HTML
const summary = document.getElementById('summary');
summary.textContent = '';
new FormData(form).forEach((v, k) => {
const row = document.createElement('div');
row.className = 'flex justify-between';
const dt = document.createElement('dt'); dt.textContent = k;
const dd = document.createElement('dd'); dd.textContent = v;
row.append(dt, dd);
summary.append(row);
});
}
panes[i].querySelector('input,select')?.focus();
}
next.addEventListener('click', () => {
// Validate only the visible step using native constraint validation
const fields = [...panes[current].querySelectorAll('input,select')];
const bad = fields.find((f) => !f.checkValidity());
if (bad) return bad.reportValidity();
if (current < panes.length - 1) show(current + 1);
else form.submit(); // or send with fetch for an in-page confirmation
});
back.addEventListener('click', () => show(current - 1));
How it works
What it solves
Long forms scare people off; the same fields split into short steps convert better. This wizard keeps everything in one real form element, so the browser, your server validation and assistive technology all see a normal form.
How it works
Every step is a fieldset marked data-step; only the current one is visible via the hidden attribute. All fields stay in the DOM, so a final submit includes every value without any state object. Moving forward runs the browser's own constraint validation on just the visible fields: checkValidity finds the first problem and reportValidity shows the native message and focuses the field, so you get localisation and accessibility for free. The form carries novalidate so hidden steps do not block submission with invisible errors.
The progress strip is a row of bars coloured up to the current index. On the last step the script reads FormData and builds the review list with textContent, which keeps typed input from being interpreted as markup.
Adapting it
- Add a step by adding a fieldset and a progress bar item.
- Replace the final
form.submit()with afetchcall for an in-page success message. - Save partial answers on the server after each step if you want drop-off recovery.
Pitfalls
- Always revalidate everything on the server, never only per step.
- Move focus to the new step heading or first field after each change, as the script does.
- Do not disable the next button; show messages instead so users know what is missing.
How to use it
- Add the form markup and the script to the page.
- Change the fields in each fieldset, keeping
data-stepon each one. - Add a matching progress item for every step.
- Set the form action and method, and validate all fields again in your backend.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.