Beware of the Leopard — Part 3: Guiding to the end
Table of contents
Guiding to the exit
Parts 1 and 2 covered the conversation between a form and its user. Asking the right questions, listening to the answers. But that conversation fit on a single page.
Some forms don't fit on a single page. A multi-step registration. A checkout with shipping, billing, payment. An application with personal information, experience, documents. Each step is a form in its own right, with its labels, its validation, its error messages. But the whole must form a continuous conversation, not a series of disconnected monologues.
This is where the parallel with the locked filing cabinet takes its full meaning: the corridors between offices. If the user doesn't know how many steps remain, can't go back without losing everything, and doesn't see a summary before final validation — they're going in circles.
The user must know where they are
A multi-step form without a progress indicator is a car journey without GPS. You're moving forward, but you don't know how many kilometers are left, or if you're on the right road.
The progress indicator must answer three questions at all times:
- Where am I : which step is current
- How much is left : total number of steps
- How long will it take : realistic estimate
<nav aria-label="Form progress">
<ol>
<li>
<a href="#step-1" aria-current="step">
<span>Step 1 of 4</span>
Personal information
</a>
</li>
<li>
<a href="#step-2">
<span>Step 2 of 4</span>
Address
</a>
</li>
<li>
<span>Step 3 of 4</span>
Payment
</li>
<li>
<span>Step 4 of 4</span>
Confirmation
</li>
</ol>
</nav>
<p>Estimated time: 3 minutes</p>aria-current="step" tells assistive technology which step is active. The screen reader announces "Step 1 of 4, Personal information, current step." The user knows exactly where they are without seeing the visual indicator.
Completed steps and the current step are links, the user can go back. Future steps are not links: their unavailability is already conveyed by the fact that they can't be activated. No need for aria-disabled on a <li>: that attribute isn't supported on the listitem role, it has no guaranteed effect. As the user progresses, steps become links.
The time estimate isn't a gimmick. For a user with motor difficulties who types slowly, or a screen reader user who navigates field by field, knowing the form takes 3 minutes lets them decide whether to start now or come back later, especially if the form offers draft saving.
The user must not lose anything
The worst multi-step form scenario: the user fills 3 steps, the browser crashes, they come back, everything is lost. Or they click "Back" to correct step 1, and step 2 is wiped.
Input persistence isn't a bonus. It's what makes the form usable.
Three mechanisms, by order of robustness:
Client-side saving
const STORAGE_KEY = 'checkout';
const SENSITIVE_TYPES = new Set(['password']);
const isSensitive = (field) =>
SENSITIVE_TYPES.has(field.type) || (field.autocomplete || '').startsWith('cc-');
const isMultiSelect = (field) => field.tagName === 'SELECT' && field.multiple;
const isPersistable = (field) =>
Boolean(field.name) && !isSensitive(field) && field.type !== 'file';
// Save: keep multi-value fields, leave out sensitive data
form.addEventListener('input', () => {
const entries = {};
for (const field of form.elements) {
if (!isPersistable(field)) continue;
if ((field.type === 'checkbox' || field.type === 'radio') && !field.checked) continue;
const value = isMultiSelect(field)
? [...field.selectedOptions].map((option) => option.value)
: field.value;
entries[field.name] = field.name in entries
? [].concat(entries[field.name], value)
: value;
}
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
});
// Restore: handle each field type according to its nature
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const entries = JSON.parse(saved);
for (const field of form.elements) {
if (!isPersistable(field)) continue;
const value = entries[field.name];
if (field.type === 'checkbox' || field.type === 'radio') {
field.checked = value !== undefined && [].concat(value).includes(field.value);
} else if (isMultiSelect(field) && Array.isArray(value)) {
for (const option of field.options) option.selected = value.includes(option.value);
} else if (typeof value === 'string') {
field.value = value;
}
}
}The previous version of this code had three bugs: field.value = value doesn't restore checkboxes or radio buttons (they need checked), Object.fromEntries(data.entries()) flattens multi-value fields (several checked boxes sharing the same name collapse into one), and the save also captured passwords and payment data.
sessionStorage persists for the browser session. The user navigating between steps or refreshing the page loses nothing. But it's not a vault: any script on the page can read it, it survives reloads, and some browsers restore it after a window is closed. So it must never hold passwords or payment data, and it gets purged after submission:
form.addEventListener('submit', () => sessionStorage.removeItem(STORAGE_KEY));Server-side saving
For long forms (job application, administrative declaration), the browser session isn't enough. The user must be able to close their browser and come back the next day. Automatic server-side saving, or a "Save draft" button, is then necessary.
Going back without losing data
When the user clicks a previous step in the stepper, data from subsequent steps must be preserved. Going back is a correction, not a reset. If the user modifies data at step 1 that invalidates step 2, a clear message must inform them, not a silent wipe.
The user must see everything before validating
You don't sign a contract without reading it. A multi-step form with consequences (payment, application, account deletion) deserves the same treatment: a complete summary before final validation.
The summary carries three responsibilities, not one:
- The link text is explicit. Four "Edit" links pointing to four targets means four indistinguishable links in a screen reader's links list. GOV.UK solves this with visually hidden text:
Change<span class="govuk-visually-hidden"> name</span>. The screen reader hears "Change name", the sighted user reads "Change". Here, we go further: the full label ("Edit full name") is visible. Voice control (Dragon speaks what it sees), cognitive impairments, low vision and zoom all benefit from an explicit label without depending on hidden text. - The link targets the field, not the step. Pointing to
#step-1drops the user at the start of a step and leaves them to find the field. The target must be the field itself. - The return to the summary is guaranteed. Once the correction is made, nothing should force the user to redo every step. That's the principle of GOV.UK's Check your answers pattern.
The markup follows GOV.UK's Summary list pattern, the second <dd> carries the action:
<section id="recap">
<h2>Review your information</h2>
<dl class="recap">
<div class="recap__row">
<dt>Full name</dt>
<dd>Yoann Fortin</dd>
<dd class="recap__action">
<a href="#name" data-return-to="recap">Edit full name</a>
</dd>
</div>
<div class="recap__row">
<dt>Email address</dt>
<dd>yoann@example.com</dd>
<dd class="recap__action">
<a href="#email" data-return-to="recap">Edit email address</a>
</dd>
</div>
<div class="recap__row">
<dt>Shipping address</dt>
<dd>12 rue des Lilas, 75011 Paris</dd>
<dd class="recap__action">
<a href="#address" data-return-to="recap">Edit shipping address</a>
</dd>
</div>
<div class="recap__row">
<dt>Shipping method</dt>
<dd>Express (24h)</dd>
<dd class="recap__action">
<a href="#shipping" data-return-to="recap">Edit shipping method</a>
</dd>
</div>
</dl>
<button type="button">Confirm order</button>
</section>A note on semantics: the second <dd> is an action, not a description of the term. That's the compromise GOV.UK settled on. If the step is switched in JavaScript without real navigation, it's no longer a link but a <button>, and the data-return-to stays the same.
The link opens the right step, places focus on the field to correct, and remembers where the user came from. When the step is validated, we return to the summary, focus on its heading:
const RECAP_ID = 'recap';
// Go to the field to correct, remembering the return point
document.querySelectorAll('[data-return-to]').forEach((trigger) => {
trigger.addEventListener('click', (event) => {
event.preventDefault();
const field = document.querySelector(trigger.getAttribute('href'));
sessionStorage.setItem('returnTo', trigger.dataset.returnTo);
goToStep(stepOf(field));
field.focus();
});
});
// After a step is validated, return to the summary if it sent us
function afterStepValidated() {
const returnTo = sessionStorage.getItem('returnTo');
if (returnTo !== RECAP_ID) return;
sessionStorage.removeItem('returnTo');
goToStep(recapStep);
const heading = document.getElementById(RECAP_ID).querySelector('h2');
heading.setAttribute('tabindex', '-1');
heading.focus();
}Two details that matter. Focus goes to the field, not the step's heading: the user has already arrived, they know why. And on the way back, focus goes to the summary's heading, not the modified row, so they hear "Review your information" and resume their overall reading.
For a screen reader user who filled 4 steps field by field, the summary is the only chance to review all their input at once. Without a summary, they must go back step by step and navigate each field to find what they entered.
For forms with financial or legal consequences, the summary isn't just good practice, it's a WCAG requirement (criterion 3.3.4).
Focus must follow
In a standard form, focus stays on the page. In a multi-step form, each transition between steps is a context change. Content disappears, new content appears. If focus doesn't follow, the keyboard user doesn't know something changed.
function goToStep(stepNumber) {
// Hide current step, show next
currentStep.hidden = true;
nextStep.hidden = false;
// Update stepper navigation
updateStepper(stepNumber);
// Move focus to the new step's heading
const heading = nextStep.querySelector('h2');
heading.setAttribute('tabindex', '-1');
heading.focus();
}On each transition, focus is sent to the new step's heading. The screen reader announces "Step 2, Address" and the user knows they've advanced. Without this focus move, the screen reader says nothing. The user is left in the void.
The same principle applies when going back: when the user clicks a previous step in the stepper, focus must land on that step's heading, not stay on the stepper link.
And if the form is a SPA that changes routes between steps, the same problem applies as with any route change. Focus must be managed manually (see the keyboard navigation article).
The overall criteria
WCAG covers forms with consequences and multi-step navigation:
- 2.1.1 Keyboard — all functionality is operable through a keyboard, including step navigation (Level A)
- 2.4.4 Link Purpose (In Context) — "Edit" repeated four times toward four targets is not explicit out of context (Level A)
- 3.2.4 Consistent Identification — labels for fields with the same function must be consistent across pages. "Email" here and "Email address" there is a cognitive trap (Level AA)
- 3.3.4 Error Prevention (Legal, Financial, Data) — for forms that cause legal commitments, financial transactions, or data modifications, submissions are reversible, checked, or confirmed (Level AA)
- 4.1.2 Name, Role, Value — stepper components have accessible names, roles, and states:
aria-current="step", focus management (Level A)
WCAG 2.1 AA is the technical standard referenced, directly or indirectly, by accessibility legislation worldwide — the ADA in the US, the Equality Act in the UK, the EAA in Europe, the ACA in Canada.
The three parts of this series together cover the full spectrum of accessible form design.