core accessibility principles for modern frameworks

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 Order
  • 3.2.2 On Input
  • 3.3.1 Error Identification
  • 4.1.3 Status Messages
Where focus lands on each kind of step transition Three transitions from step two of a wizard. Moving forward to step three sends focus to the new step's heading, which carries tabindex minus one, so the step name is announced and the next Tab enters the first field. Moving back to step one does the same with the previous heading. A failed validation attempt keeps the user on step two and sends focus to the error summary instead, so the reason is heard before anything else. Step 2 of 4 Delivery details Back Next, valid Next, invalid Step 1 heading focus moves there "Step 1 of 4, Contact" entered values preserved Step 3 heading focus moves there "Step 3 of 4, Payment" next Tab enters field one Error summary stays on step 2 "2 problems to fix" each error links to its field Every transition moves focus deliberately — the one thing a wizard must never do is leave it where the old step was.

Prerequisites

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 Tab press 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.

What happens when Next is pressed with invalid fields A sequence in four parts. The user activates Next. Validation runs and finds two invalid fields. An error summary is rendered at the top of the step, listing each problem as a link to its field, and focus moves to the summary so it is read immediately. Activating a link inside the summary moves focus to that field, where the individual message is associated by aria-describedby. A note warns that leaving focus on the Next button makes the button look broken. Next pressed focus on the button Validation fails 2 fields invalid Summary focused "2 problems to fix" Field focused via a link If focus stays on Next The screen reader says nothing, the page appears unchanged, and the user presses Next again — the most common reason a wizard is reported as "stuck".

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 sessionStorage for 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.
Where wizard state has to live for values to survive Two structures. On the left, each step component holds its own state, so unmounting a step on transition discards everything the user typed there. On the right, one state object lives above the steps and each step reads and writes into it, so unmounting a step loses nothing and revisiting it restores both values and error messages. State inside each step Step 1 · useState Step 2 · useState Unmounting on transition throws the values away — and the errors that explained them. One state object above the steps values, errors, current step Steps read and write into it, so revisiting a step restores both the input and its messages.

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.activeElement is 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.