Accessibility · Forms and Errors

Error Summaries and Focus Placement on Failed Submission

Where focus goes when a form rejects a submission, why a summary block beats a browser bubble, and how to make the summary navigable.

A submission that fails changes the state of the page without changing its scroll position. The user pressed a button at the bottom of a long form, and the response is that the page now contains error text somewhere above the viewport, in fields they have already passed. Nothing moves. A sighted user may see nothing at all; a screen reader user may hear nothing at all, because focus is still on the submit button and the new content is not in an area that gets announced.

The two decisions that resolve this are where focus goes and what the page says when it gets there. They are separable, and getting one right does not compensate for the other.

Why focus must move

Focus is the only reliable notification channel. Inserting content into the document does not announce it; a live region does, but only if one exists and only if the content change is detected as a change rather than as a page reload. Moving focus is unconditional: the browser scrolls the focused element into view, the accessibility tree reports a focus change, and the screen reader speaks the newly focused element’s name, role, and description.

Three targets are candidates on a failed submission, and they have different consequences.

Moving focus to the first invalid field is the behaviour of native validation and the behaviour most users expect. It has one weakness: the user lands in the middle of a long form with no idea how many other fields also failed. Fixing that first field and submitting again produces the second error, and the loop repeats once per field. For a form with eight errors, the user experiences eight round trips.

Moving focus to a summary at the top of the form gives the count and the list in one announcement. The user learns the scope of the problem before fixing anything. The weakness is the inverse: the user is now at the top of the form and must navigate to the field, which for a long form is a scroll and a search.

Moving focus to nothing and relying on a live region gives the count without the location, and leaves the user to find the fields themselves. It is the least intrusive and the least informative.

The pattern that resolves the trade is a summary that contains links to each field. Focus goes to the summary, the summary is announced with its count, and each entry is a link whose activation moves focus to the corresponding field. Both pieces of information arrive in the right order, and the navigation from summary to field is one keypress.

Structuring the summary

The summary is a block of content that exists in the DOM only after a failed submission, or exists always and is populated and revealed on failure. The second form is easier to focus reliably: the element is present, its id is stable, and revealing it is a change of state rather than an insertion.

<div id="error-summary" tabindex="-1" role="alert" hidden>
  <h2>2 problems prevented this form from being submitted</h2>
  <ul>
    <li><a href="#email">Email address: enter an address in the form name@example.com</a></li>
    <li><a href="#dob">Date of birth: the date must be in the past</a></li>
  </ul>
</div>

Four attributes are doing work here. tabindex="-1" makes the container programmatically focusable without adding it to the tab order; without it, focus() on a div does nothing. role="alert" gives it an implicit live region with aria-live="assertive", so that populating and revealing it produces an announcement even if focus placement fails or is delayed. hidden keeps it out of the tree and out of the announcement when there are no errors. The heading states the count, which is the single most useful piece of information in the block.

The links are the navigation. A link with href="#email" moves focus to the element with that id when activated, and browsers move focus to a target that is not natively focusable by giving it sequential focus navigation starting from that point, so the user’s next Tab continues from the target rather than from the top of the page. Adding tabindex="-1" to the field itself makes the behaviour explicit across engines. The link text should repeat the field’s label and the error, because a list of “Go to field” links is a list with no information in it.

The heading level matters for a user navigating by headings. A h2 in a page whose main content starts at h1 is correct; a summary using h3 inside a page with no h2 produces a heading outline that skips a level.

The announcement race

Setting hidden = false and calling focus() in the same task usually works and occasionally does not, because the live region announcement and the focus announcement compete. Both may fire, and the screen reader may read the alert’s full contents and then the focused element’s contents, the same text twice, or may interrupt one with the other.

Two mitigations exist and neither is fully portable. One is to put role="alert" on a container that holds only the count and not the list, so the alert announces one sentence and the focus announcement reads the list. The other is to skip the live region entirely and rely on focus, accepting that the announcement is then dependent on the focus target being a container whose contents are read on focus. Screen readers read the contents of a focused container that is not a control, so a focused div with tabindex="-1" containing a heading and a list is announced as a region with its text, which is the behaviour the pattern depends on.

What should not be done is announcing the same summary twice by combining both mechanisms on the same element without testing. Duplicate announcement is a common outcome of adding role="alert" to a summary that already receives focus.

Timing and the DOM update

Focus cannot move to an element that does not yet exist. When the summary is created on demand rather than revealed, the sequence must be: insert the element, then focus it, in that order and in the same task. Frameworks that batch DOM updates defer the insertion to a later microtask or animation frame, so a focus() call issued immediately after setting state runs against an element that has not been created. The result is a silent no-op and focus that stays on the submit button.

The reliable form of the sequence defers the focus to after the framework’s update:

function showErrors(errors) {
  renderSummary(errors);            // framework state update
  requestAnimationFrame(() => {     // after the DOM has the node
    document.getElementById('error-summary')?.focus();
  });
}

requestAnimationFrame is not a guarantee of a completed render in every framework, but it runs after the current frame’s microtasks and is the smallest deferral that reliably follows a synchronous state flush in the common frameworks. A framework that exposes a post-render hook should use it instead.

Server-rendered failures

A submission that fails on the server produces a new document rather than a DOM mutation. The summary is then in the HTML of the response, and the focus problem is different: the page has loaded, focus is at the top of the document by default, and a screen reader begins reading from the top. The user hears the page header, the navigation, and then the summary, possibly dozens of elements later.

The fix is a fragment identifier in the redirect or in the form’s action, pointing at the summary’s id. Navigating to a URL with a fragment scrolls the target into view and sets the sequential focus navigation starting point at it, and the target should carry tabindex="-1" so that it can also receive focus programmatically. Where the response is a fresh document without a fragment, the summary should be the first focusable element in the main content, before the form’s own fields, so that it is reached early in reading order.

A server-rendered summary also has to survive the loss of client state. The values the user typed are gone unless the server re-renders them into the fields, and an error message on an empty field is a message about content the user cannot see. Repopulating values is part of the recovery contract, not a nicety, and it interacts with the persistence question in Multi-Step Form State and What Survives a Step.

Errors that cannot be listed

Some failures have no field to link to: a server timeout, a duplicate account detected after submission, a payment declined. These belong in the same summary block but not as links, and their position in the list should reflect their scope. A single global error above the field-level list reads as a summary of the submission; a global error mixed into the list of field errors reads as if some field caused it.

The count in the heading should then count only the field-level problems, and a global failure should have its own sentence. “Your payment was declined” is not one of “2 problems with this form”, and conflating them gives a count that does not match the list below it.

What the summary is not

The summary is not a substitute for the per-field message. A user who returns to a field by tabbing rather than by the summary link needs the error at the field, which is the described-by wiring in Wiring Error Messages to Their Fields. The two mechanisms serve different approaches to the same failure: the summary serves the user who wants the whole picture, the field message serves the user who is working field by field.

Nor is the summary a substitute for preserving what the user entered. Focus placement and message text are worthless if the form comes back empty, and the recovery side of that is the subject of the rest of this cluster.

References

  1. Understanding Success Criterion 3.3.1: Error Identification — W3C WAI
  2. Understanding Success Criterion 3.3.3: Error Suggestion — W3C WAI
  3. WAI-ARIA 1.2: alert role — W3C