Focus Management in Multi-Step Wizards
A wizard replaces the page content while the URL may or may not change, which puts it in the worst position for assistive technology: it behaves like a navigation but the browser does not treat it as one. Press Next and a sighted user sees a new step; a screen reader user hears nothing, and a keyboard user finds focus wherever the removed button used to be — which is to say, on <body>.
Fixing it takes three decisions: where focus goes on each step change, what gets announced, and what happens when validation blocks the transition. This guide covers all three, extending Focus Management Strategies for SPAs.
WCAG Success Criteria Addressed:
2.4.3 Focus Order3.2.2 On Input3.3.1 Error Identification4.1.3 Status Messages
Prerequisites
- A wizard whose steps render into the same container (a
<form>split across steps, not separate pages). - A heading per step, and ideally a URL per step so Back works — see restoring scroll and focus on browser back navigation.
- An error summary pattern; the form validation guide covers the markup this page assumes.
Focus the Step Heading, Not the First Field
The instinct is to focus the first input of the new step. It is the wrong choice: the user hears the field label with no context, missing the step name, the progress, and any instructions above it.
Implementation Guidelines:
- Give each step an
<h2>that names it and states the position: "Step 3 of 4: Payment". - Add
tabindex="-1"to that heading and focus it after the step renders. - Do this for forward and backward transitions; the user needs the same orientation either way.
- Let the next
Tabpress take the user into the first field naturally — do not focus the field yourself as well.
'use client';
export function WizardStep({ index, total, title, children }: StepProps) {
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
// A11y rationale: 2.4.3 Focus Order — moving focus to the step heading gives
// the user the step name and their position before any field is read.
headingRef.current?.focus();
}, [index]);
return (
<section aria-labelledby={`step-${index}`}>
<h2 id={`step-${index}`} ref={headingRef} tabIndex={-1}>
Step {index} of {total}: {title}
</h2>
{children}
</section>
);
}
Focusing a heading is unusual enough that it deserves a note in the codebase: headings are not normally focusable, and the tabIndex={-1} will look like a mistake to the next reader. It keeps the heading out of the tab sequence while allowing the programmatic focus that makes the transition audible.
Make Progress Perceivable Without Repeating It
A progress indicator helps everyone, and it is easy to make it announce twice — once as part of the heading, once as a live region.
Implementation Guidelines:
- Put the position in the heading text, which is announced when focus lands there. That is usually enough on its own.
- If you also render a visual step list, mark the current step with
aria-current="step"and let the heading do the announcing. - Avoid a live region that repeats the step name; two announcements of the same fact is noise.
- Show which steps are complete visually and in text ("completed"), not by colour alone.
<ol className="wizard-progress">
{steps.map((step, index) => (
<li key={step.id}>
<span aria-current={index === current ? 'step' : undefined}>
{step.title}
</span>
{index < current && <span className="visually-hidden"> — completed</span>}
</li>
))}
</ol>
Blocked Transitions Need Their Own Focus Target
When validation fails, the user pressed Next and stayed put. Without an explicit focus move they are left on a button that appears not to work.
Implementation Guidelines:
- Render an error summary at the top of the step listing every problem, each as a link to the offending field.
- Move focus to the summary container (
tabindex="-1") so it is read immediately. - Keep the individual field messages too, associated with
aria-describedby— the summary is an index, not a replacement. - Do not disable the Next button while the step is invalid. A disabled button gives no reason and cannot be focused to ask for one.
function handleNext() {
const problems = validateStep(values);
if (problems.length === 0) { goToStep(current + 1); return; }
setErrors(problems);
// A11y rationale: 3.3.1 Error Identification — focus the summary so the reason
// the transition was refused is the next thing the user hears.
requestAnimationFrame(() => summaryRef.current?.focus());
}
Preserve Everything the User Typed
Losing input on a Back press is an accessibility problem as much as a data one: re-entering a form is disproportionately expensive with a screen reader or a switch device.
Implementation Guidelines:
- Keep all step values in one state object above the steps, not in per-step component state that unmounts.
- Restore values when a step is revisited, including partially-completed and invalid ones — do not silently discard input that failed validation.
- Persist to
sessionStoragefor long wizards so a refresh does not wipe half an hour of work. - Re-associate error messages when a step is revisited; a field that was invalid should still say why.
How to Verify
- Keyboard walk. Complete the wizard with the keyboard only. Each Next and Back should land focus on the new step's heading, with a visible ring.
- Screen reader pass. Confirm each transition announces the step name and position exactly once, and that the progress list does not repeat it.
- Blocked-transition test. Press Next with an empty required field. Focus must move to the error summary and the count must be announced.
- Back-and-return test. Go back two steps and forward again; every value, including invalid ones, should still be there with its message.
- Automated scan. Run axe on each step and assert with Testing Library that
document.activeElementis the step heading after a transition — see testing React components with jest-axe.
Common Accessibility Mistakes
- Leaving focus on the Next button. Nothing is announced, the page looks unchanged, and the user presses it again.
- Focusing the first input instead of the heading. The user hears a field label with no idea which step they are on.
- Disabling Next until the step is valid. The control gives no reason and cannot be focused to ask for one; validate on activation instead.
- Announcing the step through a live region and the heading. Two announcements of one fact.
- Unmounting step state. Going back loses the user's input, which is a much bigger cost for some users than others.
- No URL per step. The Back button leaves the whole wizard instead of going back one step, which is rarely what anyone means.
Conclusion
A wizard is a navigation that the browser does not know about, so every affordance a navigation normally provides has to be supplied by hand. Focus the step heading on every transition, put the position in the heading text rather than in a competing live region, send focus to an error summary when a transition is refused, and never discard what the user typed. Those four rules cover the entire interaction, and each of them is verifiable in under a minute with the keyboard.
There is a design implication buried in all of this: the fewer steps a wizard has, the fewer transitions there are to get wrong. Splitting a form into six steps because each screen looks tidier multiplies the focus moves, the announcements and the opportunities to lose input, and it lengthens the path for every user who now has to traverse it. Where the data model allows one longer page with clear headings, that is usually both simpler to build and faster to complete — and the accessibility work reduces to the ordinary form patterns.
Frequently Asked Questions
Should each step have its own URL? Yes where practical. It makes Back mean "previous step", allows deep linking into a resumed flow, and gives the router something to announce. If the steps cannot have URLs, intercept Back explicitly so it moves a step rather than abandoning the wizard.
Is a role="tablist" step indicator appropriate?
Only if the steps can genuinely be visited in any order, like tabs. Most wizards are sequential, and tab semantics promise free navigation the flow does not allow. An ordered list with aria-current="step" describes a sequence honestly.
Where should the error summary live in the DOM? Directly after the step heading and before the fields, so its position matches its meaning. Rendering it at the end and moving focus backwards works but reads oddly to anyone browsing the document in order.
How do I handle a step that submits to the server before continuing? Announce the wait politely if it exceeds about half a second, keep focus on the button during the request, and move focus to the new step's heading only once it renders. Moving focus while a request is in flight strands the user on a control that is about to be replaced.
Should the wizard warn before the user navigates away?
If there is unsaved input, yes — but use the browser's own beforeunload prompt rather than a custom modal for full-page navigation, since a custom dialog cannot reliably block it. For in-app navigation, a real dialog with focus management is appropriate.
Related guides
- Focus Management Strategies for SPAs — the parent guide.
- Restoring Scroll & Focus on Browser Back Navigation — making Back mean "previous step".
- Accessible Form Validation & Error States — the summary and field-message patterns used here.
- Associating Error Messages with aria-describedby — wiring individual messages to their fields.