GODRICH

9 Multi Step Form UI Patterns — Branch and Resume

A multi step form ui splits one long form into screens and collects answers in order.

Auto-plays · click a tile to jump to its section · all nine in one zip

The order follows where the person filling the form gets stuck, not the builder. First, the required consents are not all checked, so Next stays locked (01); then each step holds a different number of fields, so the box jumps (02). A fork appears where personal and business go separate ways (03), and a resume strip waits for whoever quits midway (04). Those four guide the way. The next two confirm as you type: values stack up in a side panel (05), and on a phone the finished steps fold to one line (06). The last three are the form keeping its promises: everything is laid out before you send (07), a blank sends you back to its step (08), and leaving asks once before your typing disappears (09).

01Next button that stays locked until the rules are met

If Next is clickable from the start, people cannot tell why nothing happens. This part keeps the button aria-disabled until every required rule is met, with the reason sitting right beside it. The moment the last rule lands, the lock cuts away in a single frame, and the reason line turns into a green "you can continue."

aria-disabledsteps(1, end)translateY
function lgSync() {
  var done = lgBoxes.filter(function (b) {
    return b.getAttribute('aria-checked') === 'true';
  }).length;
  var ready = done === lgBoxes.length;
  lg.classList.toggle('is-ready', ready);
  var next = document.getElementById('lg-next');
  next.setAttribute('aria-disabled', ready ? 'false' : 'true');
}

02Form height that measures each step and flows to it

When step one has two fields, step two has three, and step three has one, the box height jumps at every border. This part measures the incoming panel's real height with getBoundingClientRect() and writes it into a single length-registered custom property, --h; the box flows to that number. Because --h is registered with @property, it gains in-between values — that is the whole trick.

@property--hrole="progressbar"
@property --h {
  syntax: "<length>";
  inherits: true;
  initial-value: 63px;
}
.ah__box {
  --h: 63px;
  display: grid;
  align-items: start;
  height: var(--h);
  overflow: hidden;
  transition: --h 300ms cubic-bezier(.2, .8, .2, 1);
}

03Step path that branches on the answer you pick

A personal account and a business account do not even need the same number of steps. The moment you pick a radio, the step array is filtered and the list is drawn again from scratch: choosing business slides a "business info" slot in, three slots become four, and the equal grid tracks are rewritten to match the array length. Only the slot you stand on carries aria-current="step".

aria-currentradiogroupsteps(1, end)
function bpDraw() {
  var picked = bp.querySelector('.bp__r:checked');
  var biz = !!picked && picked.value === 'business';
  var list = bpSrc.filter(function (n) {
    return n.getAttribute('data-k') === 'all' || biz;
  });
  bpSteps.style.gridTemplateColumns =
    'repeat(' + list.length + ', 1fr)';
  list.forEach(function (n, i) {
    if (i + 1 === bpAt) li.setAttribute('aria-current', 'step');
  });
}

04Resume strip that remembers where you stopped

Long applications mostly get abandoned midway. This part writes the step number and the values to localStorage on every keystroke, then greets the returning visitor with a strip dropping in from the top: resume from step 3. Pressing resume actually restores the saved step and values, and since storage can be blocked inside an iframe, every access is wrapped in try/catch.

localStoragearia-livetranslateY
function rbRead() {
  try {
    return JSON.parse(localStorage.getItem(RB_KEY) || 'null');
  } catch (e) { return null; }
}
function rbWrite(o) {
  try { localStorage.setItem(RB_KEY, JSON.stringify(o)); } catch (e) {}
}
rbGo.textContent = rbGo.getAttribute('data-t')
  .replace('{n}', rbDraft.step);

05Side summary that stacks up as you type

The scariest moment in a checkout is not seeing what you picked. Here the summary line on the right changes at the same instant you type on the left, and the edit button on a line jumps straight back to the step holding that value and focuses the field. The summary panel carries aria-live="polite", so a screen reader re-reads it when a value changes.

aria-livetranslateXsteps(1, end)
ins.forEach(function (inp, i) {
  inp.addEventListener('input', function () {
    sp.classList.remove('is-demo');
    rows[i].querySelector('.sp__v1').textContent =
      inp.value.trim() || '—';
  });
});
rows.forEach(function (row, i) {
  row.querySelector('.sp__fix').addEventListener('click', function () {
    sp.classList.remove('is-demo');
    setStep(i);
    ins[i].focus({ preventScroll: true });
  });
});

06Vertical wizard that folds finished steps into one line

A phone screen has no room to lay steps out sideways. This part stacks them vertically, and a step whose value is filled folds as grid-template-rows goes from 1fr to 0fr, leaving a one-line header. Vertical padding survives 0fr, so the body needs an inner wrapper with min-height: 0 and overflow: hidden — that is the crux of this pattern.

grid-template-rowsaria-expandedrotate
.vz__body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 300ms cubic-bezier(.2, .8, .2, 1),
              visibility 300ms;
}
.vz__item.is-open .vz__body { grid-template-rows: 1fr; }
.vz__inner {
  min-height: 0;
  overflow: hidden;
}

07Review screen that lays everything out before you send

The last screen rewrites the values from earlier steps group by group. Each group's pencil button remembers its step number, so the pressed group lifts for a moment and its values turn into real inputs. Press again and the edited values return as text.

aria-labelscalesteps(1, end)
g.querySelectorAll('.rv__dd').forEach(function (dd) {
  var old = dd.querySelector('input');
  if (editing && !old) {
    var inp = document.createElement('input');
    inp.value = dd.textContent.trim();
    dd.textContent = '';
    dd.appendChild(inp);
  } else if (!editing && old) {
    dd.textContent = old.value.trim() || '—';
  }
});

08Submit that sends you back to the step with a blank left

What matters is getting back to the step holding the blank, not naming it. On submit, this part scans the steps from the front, returns to the first one whose value is empty, pins a warning icon on that step marker, sets aria-invalid on the field, and focuses the step heading. The distance back is longest when the first screen is the empty one, since you submit from the last — a property of this pattern worth knowing up front.

aria-invalidpreventScrolltranslateX
function irSubmit() {
  var first = -1;
  for (var i = 0; i < irPanels.length; i++) {
    var blank = irPanels[i].querySelector('.ir__in')
      .value.trim() === '';
    irPanels[i].querySelector('.ir__in')
      .setAttribute('aria-invalid', blank ? 'true' : 'false');
    if (blank && first < 0) first = i;
  }
  irGo(first, true);
  irPanels[first].querySelector('.ir__title')
    .focus({ preventScroll: true });
}

09Inline leave check that guards what you typed

Pressing Back mid-form usually summons a modal over the whole screen, which hides exactly the moment you were in. This part unfolds a confirm row right above the button instead: an empty value leaves without asking, a filled one gets role="alertdialog", and while it is open Tab cycles only between its two buttons.

alertdialogscaleYpreventScroll
lgdSheet.addEventListener('keydown', function (e) {
  if (e.key === 'Escape') { lgdOpen(false); return; }
  if (e.key !== 'Tab') return;
  e.preventDefault();
  var i = lgdBtns.indexOf(document.activeElement);
  lgdBtns[(i + 1) % 2].focus({ preventScroll: true });
});
lgdBack.addEventListener('click', function () {
  if (lgdIn.value.trim() === '') {
    lgd.classList.add('is-gone');
    return;
  }
  lgdOpen(true);
});

Where it breaks — the trap

The most common break is hard-coding a guessed height. The first build of 02 wrote down three fixed step heights; one extra line of help text pushed the sixth field outside the box. Now the next step's real height is measured into --h each time. The custom property must also be registered as a length with @property — otherwise the browser treats the value as a string, so there are no in-between values and the height snaps.

The second is the gap between turning something on and turning it off. In 06, the fold of a finished step changed far more screen area than the unfold, because the whole body vanished at once. A 24-frame preview picks the biggest change as the poster, so the fold became everyone's first impression. The loop staggers the two and keeps cuts off the folding side. Item 09 hit the same wall: the closing confirm row changed more than the opening one, and an empty screen won the poster. Opening now fills a single frame slot and closing spreads the same change across four, and only then does a frame with the actual subject survive.

The third is asking without a condition. The first draft of 09 opened the confirm row on every Back press. Asking someone who typed nothing "your values will be lost" is noise. The row now opens only when value.trim() !== ''. Item 08 shares the logic: a check that runs only on the last step never sees a blank left earlier, which is why it scans from the front.

Narrow screens break it last. Each part sits in a 480×300 slot on the page and shrinks to a 320×200 slot on a phone. After stage padding, 174px of height is all there is; measured at 320px wide, the review list was the tallest of the nine. So on narrow screens each group shows only its first value, the font drops to 10px, and vertical spacing goes down, never up. The nine sets live in one folder, and the archive password is gyxeywcf — the vanilla and React versions inside carry the same values you are reading.

Accessibility (reduced-motion)

With reduced motion on, all nine loops stop and only result states remain: the unlocked button keeps its unlocked color, a folded step stays folded with its one-line summary, and the leave check freezes on its open frame. What was decided must not disappear just because motion did.

Step-form accessibility comes down to two roles. Progress carries role="progressbar" with aria-valuenow updated to real numbers (02), and the current step carries aria-current="step" (03). Field trouble is aria-invalid (08), lock is aria-disabled (01), fold is aria-expanded (06). Focus is actually handled too: when a step changes, its heading receives focus with preventScroll, because these parts sit inside iframes on the home grid and an unguarded focus call drags the parent page along.

Item By eye By screen reader Control
01 Step lock locked button → live color aria-disabled toggled click
02 Height flow box grows and shrinks progressbar value updated next · prev · arrow keys
03 Branching path 3 slots ↔ 4 slots redrawn aria-current="step" radio
04 Resume strip strip drops from the top resume notice via aria-live resume · start over
05 Side summary summary line mirrors typing summary panel aria-live typing · edit · ↑↓
06 Vertical wizard finished step folds to one line aria-expanded · summary read out header click · Enter
07 Review screen group lifts and settles pencil button aria-label click
08 Blank return warning icon + red field aria-invalid · heading focus submit
09 Leave check confirm row above the button alertdialog message Escape · Tab

Which attributes a multi-step form needs is laid out in the MDN aria-current reference. Sibling input parts live under the forms category, and the parts with real JavaScript behind them live under the JS category.

FAQ

Can the steps be split with CSS alone?

Hiding screens, yes. With :target or radios, you can swap which panel shows on click. But "a blank cannot advance" and "remember what was typed" are beyond CSS: reading, comparing, and storing values is JavaScript's job, so all nine ship those few lines together. A form that only flips screens cannot pass the 08 check.

What should I keep in localStorage?

One object holding the step number and the values is enough. Item 04 stores { step: 3, v: '12 Main St' }. Keys must differ per form, or two users on one browser will read each other's drafts, and the whole access is wrapped in try/catch for blocked contexts such as iframes. Sensitive values are better left unsaved.

Do these break past ten steps?

Items 02 and 06 do not care about the count — height flow and fold do no extra work per step, so twenty steps behave like three. Redrawing the whole list as in 03 gets heavier as steps grow, so front-load the forks into the first few steps. People cannot count a ten-slot step bar at a glance either.

Enter the archive password

The password is inside this article. You will find it as you read.