On this page
When steps actually help
Splitting a form into steps reduces the perceived cost of starting. Three short screens feel lighter than one long one, even when the field count is identical.
But steps have a cost too: more clicks, more chances to abandon between screens, and a build with real state to manage. They pay off when the form has roughly eight or more fields, when fields group into obvious themes ("about you" / "about your project" / "budget and timing"), or when later questions depend on earlier answers.
They don't pay off for a five-field contact form. Splitting that into two steps adds a click and removes the visitor's ability to see what they're committing to.
One rule that survives every version of this: put the easy questions first and the sensitive ones last. Name and email before budget. Once someone has invested three answers, they're markedly more likely to finish, and if they abandon at the budget question you at least captured contact details on the earlier step — if you built it that way, which is the last section of this article.
The structure
One Webflow form, several step divs inside it, one set of navigation controls. Crucially it stays a single form element, so the final submit sends everything at once and Webflow's own submission handling still works.
<form>
<div class="step" data-step="1">
<input name="name" required>
<input name="email" type="email" required>
</div>
<div class="step" data-step="2" hidden>
<select name="project-type" required>...</select>
<textarea name="details"></textarea>
</div>
<div class="step" data-step="3" hidden>
<select name="budget" required>...</select>
</div>
<div class="controls">
<button type="button" class="back" hidden>Back</button>
<button type="button" class="next">Continue</button>
<input type="submit" class="submit" value="Send" hidden>
</div>
<div class="progress"><span class="bar"></span></div>
<p class="step-status" aria-live="polite"></p>
</form>
Note the button types. Everything except the final control is type="button" — a bare <button> inside a form defaults to type="submit", which means clicking "Continue" submits the whole form on step one. That single detail is behind a large share of broken multi-step builds.
The script
(function () {
const form = document.querySelector('form');
if (!form) return;
const steps = Array.from(form.querySelectorAll('.step'));
const next = form.querySelector('.next');
const back = form.querySelector('.back');
const submit = form.querySelector('.submit');
const bar = form.querySelector('.bar');
const status = form.querySelector('.step-status');
let index = 0;
function render() {
steps.forEach((step, i) => { step.hidden = i !== index; });
back.hidden = index === 0;
next.hidden = index === steps.length - 1;
submit.hidden = index !== steps.length - 1;
bar.style.width = ((index + 1) / steps.length * 100) + '%';
status.textContent = `Step ${index + 1} of ${steps.length}`;
// Move focus to the first field so keyboard and screen reader users
// land in the right place instead of at the top of the document.
const first = steps[index].querySelector('input, select, textarea');
if (first) first.focus();
}
function valid() {
// Only validate the visible step. checkValidity() on the whole form
// would fail on required fields in steps the visitor hasn't reached.
const fields = Array.from(steps[index].querySelectorAll('input, select, textarea'));
for (const field of fields) {
if (!field.checkValidity()) {
field.reportValidity();
return false;
}
}
return true;
}
next.addEventListener('click', () => {
if (!valid()) return;
index = Math.min(index + 1, steps.length - 1);
render();
save();
});
back.addEventListener('click', () => {
index = Math.max(index - 1, 0);
render();
});
render();
})();
The validation function is the part worth reading twice. Calling form.checkValidity() validates every required field including ones in hidden steps, which fails immediately and — because the browser can't focus a hidden invalid field — often fails with no visible explanation. Validating only the current step is the whole trick.
Not losing answers on refresh
The most common complaint about multi-step forms is losing everything to a mistimed refresh or back-navigation. sessionStorage fixes it in a dozen lines:
const KEY = 'form-draft';
function save() {
const data = {};
new FormData(form).forEach((value, key) => { data[key] = value; });
sessionStorage.setItem(KEY, JSON.stringify({ data, index }));
}
function restore() {
const raw = sessionStorage.getItem(KEY);
if (!raw) return;
try {
const { data, index: saved } = JSON.parse(raw);
Object.entries(data).forEach(([key, value]) => {
const field = form.elements[key];
if (field) field.value = value;
});
if (typeof saved === 'number') index = saved;
} catch (e) {
sessionStorage.removeItem(KEY);
}
}
form.addEventListener('submit', () => sessionStorage.removeItem(KEY));
Call restore() before the first render(), and save() on each step change and on input.
sessionStorage rather than localStorage is deliberate: the draft dies with the tab. A half-finished enquiry restored three weeks later on a shared computer is a small privacy problem you don't need.
Never save sensitive values this way. Card numbers, government IDs, health details — those don't belong in browser storage at all. If your form collects them, exclude those fields explicitly.
Progress that tells the truth
A progress bar sets expectations, and an honest one matters more than a pretty one.
Show "Step 2 of 3", not a percentage — people convert percentages into steps anyway, and a bar at 66% with two long questions left feels like a lie. If steps vary a lot in length, weight the bar by field count rather than step count.
If a step is conditional — skipped based on an earlier answer — recalculate the total when that branch is decided, rather than showing "step 3 of 5" and finishing at 4.
Keep the aria-live="polite" status line. Screen reader users get no automatic announcement when a step changes, and without it the form appears to do nothing on "Continue".
Capturing partial completions
The strategic question: someone fills in name and email on step one, then abandons at budget. Do you have a lead?
By default, no. Nothing is sent until final submit.
If those partials are valuable, send step one separately — a background fetch to your CRM or a webhook when the visitor advances past the first step:
async function capturePartial() {
const data = new FormData(form);
await fetch('/your-endpoint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: data.get('name'),
email: data.get('email'),
status: 'partial',
}),
}).catch(() => {}); // never block the visitor on this
}
Two obligations if you do this. Say so in your privacy notice — you're collecting data before the person pressed submit, and that surprises people. And mark those records as partial in your CRM, or your sales team will call someone who never actually asked to be contacted, which is both annoying and, in several jurisdictions, a compliance problem.
The .catch(() => {}) matters: a failed background capture must never prevent someone completing the form. The visitor's submission is the thing that counts.
Before you ship
- Click "Continue" on step one with empty fields — it should stop, not submit
- Tab through every step with the mouse untouched
- Refresh mid-form and confirm the draft restores
- Submit the whole thing and check the submission contains fields from every step
- Try it on a phone: check the keyboard doesn't cover the Continue button
That last one catches more real abandonment than anything else on the list.