A multi-step wizard has an accessibility contract with four clauses — move focus to the step heading on every transition, announce “step X of N”, restore focus correctly on Back, and (only for modal wizards) trap focus inside the active step — and violating any one of them strands keyboard and screen-reader users mid-flow.
The bug this page fixes: the user clicks Next, the new step renders, but focus is orphaned on the now-removed Next button or reset to the top of the document. A screen-reader user hears nothing about which step they are on; a keyboard user is tabbing from <body>. Clicking Back is worse — focus jumps to the top instead of returning to where the user left off.
This extends focus management after validation across step boundaries, and it leans on the same keyboard navigation patterns that govern focus order within a single view.
The four-clause contract
Each transition in a wizard is a mini page navigation, and SPAs get no free focus reset the way full page loads do. You must reproduce that reset deliberately:
- Heading focus, not input focus. On entering a step, focus its
<h2>(made focusable withtabindex="-1"). The screen reader reads the step title; the user learns where they are before the fields. - Position announcement. A live region announces “Step 3 of 5: Payment details” so the user knows progress without seeing a stepper.
- Restorable focus. Forward navigation records the trigger element; Back restores focus to the corresponding trigger on the previous step, not the top of the page.
- Conditional trap. If the wizard is a modal dialog, focus is trapped within the step and the rest of the page is
inert. If it is inline, focus flows normally and is not trapped.
Core implementation
The controller owns a focus stack for Back restoration, a live region for the position announcement, and the transition method that ties them together. Focus moves after the new step commits to the DOM.
interface WizardStep {
id: string;
index: number; // 0-based
heading: HTMLElement; // an <h2 tabindex="-1"> inside the step
container: HTMLElement;
}
class WizardFocusController {
// Per-transition record of the element to restore focus to on Back.
private focusStack: (HTMLElement | null)[] = [];
constructor(
private steps: WizardStep[],
private liveRegion: HTMLElement, // aria-live="polite"
private opts: { modal?: boolean } = {}
) {}
/**
* Advance to `toIndex`. `trigger` is the element the user activated
* (the Next button), recorded so Back can return focus to it.
* Await render before focusing: the new heading must exist and be
* laid out, or focus() is a silent no-op.
*/
async goForward(toIndex: number, trigger: HTMLElement | null): Promise<void> {
this.focusStack.push(trigger);
await this.renderStep(toIndex);
this.enterStep(toIndex);
}
/** Step back and restore focus to the recorded trigger. */
async goBack(toIndex: number): Promise<void> {
const restore = this.focusStack.pop() ?? null;
await this.renderStep(toIndex);
const step = this.steps[toIndex];
// Restore to the trigger if it is still connected; else the heading.
if (restore && restore.isConnected) {
restore.focus({ preventScroll: true });
restore.scrollIntoView({ block: "center" });
} else {
this.focusHeading(step);
}
this.announce(step);
if (this.opts.modal) this.trapFocus(step.container);
}
private enterStep(index: number): void {
const step = this.steps[index];
this.focusHeading(step);
this.announce(step);
if (this.opts.modal) this.trapFocus(step.container);
}
private focusHeading(step: WizardStep): void {
// Heading carries tabindex="-1" so it is focusable without being a
// tab stop. preventScroll then a centred scroll gives one clean jump.
step.heading.focus({ preventScroll: true });
step.heading.scrollIntoView({ block: "start" });
}
private announce(step: WizardStep): void {
const label = step.heading.textContent?.trim() ?? "";
this.liveRegion.textContent = "";
requestAnimationFrame(() => {
this.liveRegion.textContent =
`Step ${step.index + 1} of ${this.steps.length}: ${label}`;
});
}
/**
* Modal-only focus trap. Cycles Tab within the step's focusable set.
* Returns nothing; the trap listener is removed when the next step
* installs its own or when destroy() runs.
*/
private trapListener?: (e: KeyboardEvent) => void;
private trapFocus(container: HTMLElement): void {
if (this.trapListener) document.removeEventListener("keydown", this.trapListener);
const focusables = () =>
Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
)
).filter((el) => el.offsetParent !== null); // visible only
this.trapListener = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const items = focusables();
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", this.trapListener);
}
private renderStep(index: number): Promise<void> {
// Your framework commits the step DOM here (setState / signal / store).
// Resolve on the next frame so layout is ready before focus.
this.commit(index);
return new Promise((r) => requestAnimationFrame(() => r()));
}
private commit(_index: number): void {
/* framework-specific render */
}
destroy(): void {
if (this.trapListener) document.removeEventListener("keydown", this.trapListener);
this.focusStack.length = 0;
}
}
Step-by-step walkthrough
- Headings are focusable, not tab stops. Each step
<h2>getstabindex="-1", so scripts can focus it but it never becomes a Tab stop that traps sequential navigation. - Forward records the trigger.
goForwardpushes the activating element (the Next button) ontofocusStackbefore rendering, so its identity survives the transition. - Focus moves after render.
renderStepcommits the new step and resolves onrequestAnimationFrame, guaranteeing the heading exists and is laid out beforefocus()runs. - Position is announced.
announcewrites “Step 3 of 5: Payment details” to a polite live region using the clear-then-set trick so repeated navigation still speaks. - Back restores the trigger.
goBackpops the stack and focuses the recorded trigger if it is still connected, falling back to the step heading otherwise. - Trap only when modal.
trapFocusinstalls a Tab-cycling keydown listener only whenopts.modalis set; inline wizards leave focus free to reach the rest of the page.
Failure modes and edge cases
Focusing before the step renders
Calling focus() synchronously after triggering a state change focuses an element that does not exist yet. Always await a frame.
// WRONG — heading not in the DOM yet
setStep(next);
steps[next].heading.focus();
// RIGHT — commit, then focus on the next frame
setStep(next);
await new Promise((r) => requestAnimationFrame(() => r(null)));
steps[next].heading.focus({ preventScroll: true });
Trapping focus in an inline wizard
A focus trap on a non-modal wizard prevents the user from reaching skip links, the browser chrome, and content after the form. Only trap when the step is a role="dialog" with the background made inert. Gate the trap on opts.modal as shown, never install it unconditionally.
Back restores a detached trigger
If the previous step re-renders and the recorded Next button is a new node, the stored reference is detached and focus() is a no-op. The restore.isConnected guard falls back to the heading, keeping focus somewhere sensible.
Announcement swallowed by the focus move
Some screen readers drop a live-region update that lands in the same tick as a focus change, because the focus announcement wins. The requestAnimationFrame delay in announce separates the two events so both are read.
Trap listener leaks across steps
Installing a new trap without removing the old one stacks keydown listeners, and after several steps Tab behaves erratically. trapFocus removes the previous listener before adding a new one, and destroy removes the last one on teardown — the same discipline any keyboard navigation pattern requires.
Verification checklist
Frequently Asked Questions
Where should focus go when a wizard advances to the next step?
Move focus to the new step’s heading, not to its first input. Focusing the heading (with tabindex="-1") lets the screen reader read the step title and position before the user starts filling fields, and it gives keyboard users a predictable top-of-content anchor. Focusing the first input skips the step context entirely, so the user never hears which step they landed on.
How do I restore focus when the user clicks Back?
When navigating forward, record the element that triggered the transition, typically the Next button or the field that submitted the step. On Back, focus the recorded trigger from the step you are returning to, or fall back to that step’s heading if the trigger no longer exists. Keep a per-step stack of last-focused elements — the controller pushes on goForward and pops on goBack, guarding with isConnected.
Should a multi-step form wizard trap focus like a modal?
Only if the wizard is presented as a modal dialog that overlays the page. An inline wizard that is part of the page flow should not trap focus, because the user must reach the browser chrome, skip links, and surrounding content. Trap focus only when the step is inside a role="dialog" with the rest of the page inert — the implementation gates the trap on an explicit modal option for exactly this reason.