SolutionsCase StudiesAI ToolsResourcesAboutContact
Get Your Free AI Automation Audit Book a Free 30-Minute Strategy Call
Playground~/frontend Open editor

~/css/multi-step-forms

7 CSS Multi-Step Forms

3 Pure CSS4 with JSPublished 2026-10-05Free · no sign-up

Seven patterns for breaking a long form into steps: a three-step sign-up with validation, a numbered checkout header, a first step of large choice cards, a review step with edit buttons, one question per screen, a side step list that stacks on phones and a step that saves progress as you type.

01. Three-Step Form with Progress

FormSteps

A sign-up split into three fieldsets, one shown at a time, with a Step 2 of 3 label and a progress bar. Next checks the current step's fields with reportValidity before moving on, and focus moves to the new step's legend so screen reader users hear where they are.

View code
<form class="msf1" novalidate><p class="cnt" aria-live="polite">Step 1 of 3</p><div class="bar"><span></span></div><fieldset><legend tabindex="-1">Your details</legend><label>Name<input required autocomplete="name"></label><label>Email<input type="email" required autocomplete="email"></label></fieldset><fieldset hidden><legend tabindex="-1">Your shop</legend><label>Shop name<input required></label><label>Website<input type="url" placeholder="https://"></label></fieldset><fieldset hidden><legend tabindex="-1">Choose a plan</legend><label class="r"><input type="radio" name="p" checked> Starter</label><label class="r"><input type="radio" name="p"> Growth</label></fieldset><div class="act"><button type="button" class="back" hidden>Back</button><button type="button" class="next">Next</button></div></form>
.msf1 { width: 300px; max-width: 100%; box-sizing: border-box; font: 14px Inter, system-ui, sans-serif; color: #101828; }
.msf1 .cnt { margin: 0 0 6px; color: #475467; font-weight: 600; }
.msf1 .bar { height: 6px; border-radius: 3px; background: #eaecf0; margin-bottom: 14px; }
.msf1 .bar span { display: block; width: 33.3%; height: 100%; border-radius: 3px; background: #2563eb; transition: width .3s; }
.msf1 fieldset { display: grid; gap: 10px; min-inline-size: 0; margin: 0; padding: 0; border: 0; }
.msf1 legend { padding: 0; margin-bottom: 4px; font-weight: 800; font-size: 16px; }
.msf1 label { display: grid; gap: 4px; font-weight: 600; }
.msf1 label.r { display: flex; align-items: center; gap: 8px; padding: 10px; border: 1px solid #d0d5dd; border-radius: 8px; }
.msf1 input:not([type=radio]) { padding: 10px; border: 1px solid #98a2b3; border-radius: 8px; font: inherit; }
.msf1 .act { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
.msf1 button { padding: 10px 18px; border-radius: 8px; font: 700 14px Inter, system-ui, sans-serif; cursor: pointer; }
.msf1 .next { border: 0; background: #2563eb; color: #fff; }
.msf1 .back { border: 1px solid #d0d5dd; background: #fff; color: #344054; }
.msf1 :focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }
var f = document.querySelector('.msf1'), sets = f.querySelectorAll('fieldset'), i = 0;
var next = f.querySelector('.next'), back = f.querySelector('.back');
function show(n) {
  sets[i].hidden = true; i = n; sets[i].hidden = false;
  f.querySelector('.cnt').textContent = 'Step ' + (i + 1) + ' of ' + sets.length;
  f.querySelector('.bar span').style.width = (i + 1) / sets.length * 100 + '%';
  back.hidden = i === 0; next.textContent = i === sets.length - 1 ? 'Create account' : 'Next';
  sets[i].querySelector('legend').focus();
}
next.onclick = function () {
  var ok = Array.prototype.every.call(sets[i].querySelectorAll('input'), function (x) { return x.reportValidity(); });
  if (!ok) return;
  if (i < sets.length - 1) show(i + 1); else f.querySelector('.cnt').textContent = 'Account created (demo)';
};
back.onclick = function () { show(i - 1); };

02. Numbered Steps Header

FormStepper

The top of a checkout form: numbered circles for Cart, Address and Payment joined by lines that fill as steps complete. The list is ordered, the current step has aria-current set to step, and completed steps carry the word done for screen readers as well as a tick.

View code
<div class="msf2"><ol><li class="done"><span class="n" aria-hidden="true">✓</span>Cart<span class="sr"> (done)</span></li><li aria-current="step"><span class="n" aria-hidden="true">2</span>Address</li><li><span class="n" aria-hidden="true">3</span>Payment</li></ol><form><label>Street address<input autocomplete="street-address"></label><button type="button">Continue to payment</button></form></div>
.msf2 { width: 320px; max-width: 100%; box-sizing: border-box; font: 14px Inter, system-ui, sans-serif; color: #e2e8f0; }
.msf2 ol { display: flex; margin: 0 0 18px; padding: 0; list-style: none; }
.msf2 li { position: relative; flex: 1; display: grid; justify-items: center; gap: 6px; color: #94a3b8; font-size: 13px; font-weight: 600; }
.msf2 li + li::before { content: ""; position: absolute; top: 15px; right: 50%; width: 100%; height: 2px; background: #334155; z-index: -1; }
.msf2 li.done + li::before, .msf2 li.done::before { background: #22c55e; }
.msf2 .n { display: grid; place-items: center; width: 32px; height: 32px; border: 2px solid #334155; border-radius: 50%; background: #0e1118; }
.msf2 .done .n { border-color: #22c55e; background: #22c55e; color: #052e16; }
.msf2 [aria-current] { color: #fff; } .msf2 [aria-current] .n { border-color: #60a5fa; color: #fff; }
.msf2 .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.msf2 ol { isolation: isolate; }
.msf2 form { display: grid; gap: 12px; } .msf2 label { display: grid; gap: 6px; font-weight: 600; }
.msf2 input { padding: 10px; border: 1px solid #475569; border-radius: 8px; background: #111827; color: #fff; font: inherit; }
.msf2 button { padding: 11px; border: 0; border-radius: 8px; background: #3b82f6; color: #fff; font: 700 14px Inter, system-ui, sans-serif; cursor: pointer; }
.msf2 :focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }

03. Pick-a-Card Step

FormRadio

A first step that asks one question with large selectable cards. Each card is a label wrapping a real radio button, so arrow keys move between them, and :has(:checked) styles the chosen card. The Continue button stays disabled until something is picked.

View code
<form class="msf3"><fieldset><legend>What are you building?</legend><label><input type="radio" name="k" value="shop"><b>An online shop</b><span>Products, cart and checkout</span></label><label><input type="radio" name="k" value="site"><b>A company site</b><span>Pages, blog and contact form</span></label><label><input type="radio" name="k" value="app"><b>A web app</b><span>Accounts and dashboards</span></label></fieldset><button disabled>Continue</button></form>
.msf3 { display: grid; gap: 12px; width: 300px; max-width: 100%; box-sizing: border-box; font: 14px Inter, system-ui, sans-serif; color: #101828; }
.msf3 fieldset { display: grid; gap: 8px; min-inline-size: 0; margin: 0; padding: 0; border: 0; }
.msf3 legend { margin-bottom: 6px; padding: 0; font-weight: 800; font-size: 17px; }
.msf3 label { display: grid; grid-template-columns: auto 1fr; column-gap: 10px; align-items: center; padding: 12px; border: 1px solid #d0d5dd; border-radius: 12px; background: #fff; cursor: pointer; }
.msf3 input { grid-row: span 2; width: 18px; height: 18px; accent-color: #7f56d9; margin: 0; }
.msf3 span { color: #475467; font-size: 13px; }
.msf3 label:has(:checked) { border-color: #7f56d9; box-shadow: 0 0 0 1px #7f56d9; background: #f9f5ff; }
.msf3 label:has(:focus-visible) { outline: 3px solid #d6bbfb; outline-offset: 2px; }
.msf3 button { padding: 11px; border: 0; border-radius: 10px; background: #7f56d9; color: #fff; font: 700 14px Inter, system-ui, sans-serif; cursor: pointer; }
.msf3 button:disabled { background: #e4e7ec; color: #475467; cursor: not-allowed; }
.msf3 button:focus-visible { outline: 3px solid #d6bbfb; outline-offset: 2px; }
var f = document.querySelector('.msf3');
f.addEventListener('change', function () { f.querySelector('button').disabled = false; });
f.addEventListener('submit', function (e) { e.preventDefault(); });

04. Review Step with Edit Links

FormSummary

The last step before submitting: a description list of everything entered, each with an Edit button that names what it edits (Edit email, not just Edit). Showing a summary lets people catch mistakes before they commit, which matters most for orders and bookings.

View code
<section class="msf4" aria-labelledby="msf4h"><h3 id="msf4h">Check your booking</h3><dl><div><dt>Date</dt><dd>Sat 18 October, 7:30 pm</dd><button type="button" aria-label="Edit date">Edit</button></div><div><dt>Guests</dt><dd>4 people</dd><button type="button" aria-label="Edit guests">Edit</button></div><div><dt>Email</dt><dd>sam@example.com</dd><button type="button" aria-label="Edit email">Edit</button></div></dl><button class="go">Confirm booking</button></section>
.msf4 { width: 300px; max-width: 100%; box-sizing: border-box; font: 14px Inter, system-ui, sans-serif; color: #101828; }
.msf4 h3 { margin: 0 0 10px; font-size: 17px; }
.msf4 dl { margin: 0 0 14px; border: 1px solid #e4e7ec; border-radius: 12px; background: #fff; }
.msf4 dl div { display: grid; grid-template-columns: 1fr auto; padding: 10px 12px; }
.msf4 dl div + div { border-top: 1px solid #e4e7ec; }
.msf4 dt { grid-column: 1; color: #475467; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
.msf4 dd { grid-column: 1; margin: 2px 0 0; font-weight: 600; overflow-wrap: anywhere; }
.msf4 dl button { grid-column: 2; grid-row: 1 / span 2; align-self: center; padding: 6px 10px; border: 0; border-radius: 6px; background: transparent; color: #1d4ed8; font: 700 13px Inter, system-ui, sans-serif; cursor: pointer; }
.msf4 dl button:hover { background: #eff4ff; }
.msf4 .go { width: 100%; padding: 12px; border: 0; border-radius: 10px; background: #1d4ed8; color: #fff; font: 700 15px Inter, system-ui, sans-serif; cursor: pointer; }
.msf4 :focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }

05. One Question per Screen

FormOnboarding

An onboarding flow that asks a single question at a time, sliding the next one in from the right. Enter moves forward, the question is the label of its input so it is announced when focused, and a small counter shows how far along the person is.

View code
<form class="msf5"><p class="c">1 / 3</p><div class="q on"><label for="q1">What should we call you?</label><input id="q1" autocomplete="given-name" placeholder="Your first name"></div><div class="q"><label for="q2">What do you sell?</label><input id="q2" placeholder="Candles, prints, coffee…"></div><div class="q"><label for="q3">Where are you based?</label><input id="q3" autocomplete="country-name" placeholder="Country"></div><button>Next &rarr;</button></form>
.msf5 { display: grid; gap: 14px; width: 300px; max-width: 100%; box-sizing: border-box; overflow: hidden; font-family: Inter, system-ui, sans-serif; }
.msf5 .c { margin: 0; color: #818cf8; font: 700 13px "JetBrains Mono", ui-monospace, monospace; }
.msf5 .q { display: none; gap: 10px; }
.msf5 .q.on { display: grid; animation: msf5 .35s ease both; }
.msf5 label { color: #f1f5ff; font-size: 22px; font-weight: 800; line-height: 1.25; }
.msf5 input { min-width: 0; padding: 12px 0; border: 0; border-bottom: 2px solid #3b4670; background: transparent; color: #fff; font: 500 18px Inter, system-ui, sans-serif; }
.msf5 input:focus { outline: none; border-bottom-color: #818cf8; }
.msf5 button { justify-self: start; padding: 11px 18px; border: 0; border-radius: 10px; background: #6366f1; color: #fff; font: 700 14px Inter, system-ui, sans-serif; cursor: pointer; }
.msf5 button:focus-visible { outline: 3px solid #c7d2fe; outline-offset: 2px; }
@keyframes msf5 { from { opacity: 0; transform: translateX(30px); } }
@media (prefers-reduced-motion: reduce) { .msf5 .q.on { animation: none; } }
var f = document.querySelector('.msf5'), qs = f.querySelectorAll('.q'), i = 0;
f.addEventListener('submit', function (e) {
  e.preventDefault();
  if (i === qs.length - 1) { f.querySelector('.c').textContent = 'All done'; return; }
  qs[i].classList.remove('on'); i++; qs[i].classList.add('on');
  f.querySelector('.c').textContent = (i + 1) + ' / ' + qs.length;
  qs[i].querySelector('input').focus();
});

06. Side Step List Layout

FormLayout

A wider layout with the step list beside the form, which stacks above it on narrow screens through a container query. The list is navigation between sections, so each completed step is a link back, the current one is marked, and future steps are plain text.

View code
<div class="msf6"><div class="in"><ol><li class="d"><a href="#account">Account</a></li><li aria-current="step">Company</li><li>Team</li></ol><form><h3>Company details</h3><label>Company name<input autocomplete="organization"></label><label>Team size<select><option>1-10</option><option>11-50</option><option>51+</option></select></label><button type="button">Save and continue</button></form></div></div>
.msf6 { container-type: inline-size; width: 460px; max-width: 100%; box-sizing: border-box; font: 14px Inter, system-ui, sans-serif; color: #101828; }
.msf6 .in { display: grid; gap: 16px; }
.msf6 ol { display: flex; gap: 6px; margin: 0; padding: 0; list-style: none; counter-reset: s; }
.msf6 li { counter-increment: s; flex: 1; padding: 8px 10px; border-radius: 8px; background: #f2f4f7; color: #475467; font-weight: 600; font-size: 13px; }
.msf6 li::before { content: counter(s) ". "; }
.msf6 li.d { background: #ecfdf3; } .msf6 li.d a { color: #067647; }
.msf6 li[aria-current] { background: #101828; color: #fff; }
.msf6 form { display: grid; gap: 10px; } .msf6 h3 { margin: 0; font-size: 17px; }
.msf6 label { display: grid; gap: 4px; font-weight: 600; }
.msf6 input, .msf6 select { min-width: 0; padding: 10px; border: 1px solid #98a2b3; border-radius: 8px; font: inherit; background: #fff; }
.msf6 button { padding: 11px; border: 0; border-radius: 8px; background: #101828; color: #fff; font: 700 14px Inter, system-ui, sans-serif; cursor: pointer; }
.msf6 :focus-visible { outline: 3px solid #84adff; outline-offset: 2px; }
@container (min-width: 420px) { .msf6 .in { grid-template-columns: 130px 1fr; } .msf6 ol { flex-direction: column; align-self: start; } }

07. Progress Saved Between Steps

FormStorage

Long forms lose people when a tab closes. This step saves each field to sessionStorage as it is typed and restores it on load, with a quiet Saved note in a live region. Clearing the form removes the stored copy, and nothing ever leaves the browser.

View code
<form class="msf7"><h3>Step 2 of 4 · About you</h3><label for="m7b">Short bio</label><textarea id="m7b" rows="4" name="bio"></textarea><div class="row"><p class="s" aria-live="polite"></p><button type="reset">Clear</button></div></form>
.msf7 { display: grid; gap: 8px; width: 300px; max-width: 100%; box-sizing: border-box; font: 14px Inter, system-ui, sans-serif; color: #e2e8f0; }
.msf7 h3 { margin: 0 0 4px; font-size: 16px; color: #fff; }
.msf7 label { font-weight: 600; }
.msf7 textarea { min-width: 0; padding: 10px; border: 1px solid #475569; border-radius: 8px; background: #111827; color: #fff; font: inherit; resize: vertical; }
.msf7 .row { display: flex; align-items: center; justify-content: space-between; }
.msf7 .s { margin: 0; color: #86efac; font-size: 13px; }
.msf7 button { padding: 8px 14px; border: 1px solid #475569; border-radius: 8px; background: transparent; color: #e2e8f0; font: 600 13px Inter, system-ui, sans-serif; cursor: pointer; }
.msf7 :focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }
var f = document.querySelector('.msf7'), t = f.querySelector('textarea'), s = f.querySelector('.s'), key = 'msf7-bio', tm;
try { t.value = sessionStorage.getItem(key) || ''; } catch (e) {}
t.addEventListener('input', function () {
  clearTimeout(tm);
  tm = setTimeout(function () { try { sessionStorage.setItem(key, t.value); s.textContent = 'Saved'; } catch (e) { s.textContent = 'Could not save in this browser'; } }, 400);
});
f.addEventListener('reset', function () { try { sessionStorage.removeItem(key); } catch (e) {} s.textContent = 'Cleared'; });

About these CSS Multi-Step Forms

Say where people are

Show Step 2 of 3 in text, not only as dots or a bar, and mark the current step with aria-current="step". When the step changes, move focus to its heading or legend so screen reader users hear the new step.

Validate each step

Check the fields of the current step before moving on. reportValidity() on each input shows the browser's own messages and puts focus on the first problem, with no extra validation library.

Group steps with fieldsets

Each step is a <fieldset> with a <legend>, so the step's name is announced with its fields. Hide the steps that are not active with the hidden attribute so they cannot be tabbed into.

Let people go back and review

A Back button and a final review step catch mistakes before submission. Keep what was typed when moving between steps, and consider saving it so a closed tab does not lose the work.

Frequently asked questions

Is a multi-step form better than one long form?

For long or branching forms, usually yes: each step feels short. For three or four fields, one page is faster.

Does the saved-progress demo send my data anywhere?

No. It uses sessionStorage, which stays in your browser and is cleared when the tab closes.

Can I submit all steps as one form?

Yes. Keep every fieldset inside one form element; hidden fieldsets still submit their values.