The exact problem: a form renders a list of errors above the fields after a failed submit, and a keyboard or screen-reader user never learns it exists — because nothing moved focus, nothing was announced, and the list is not reachable from where they are.
Context and Prerequisites
This is the implementation of the summary described in error summary and messaging, and it assumes the branch rule from focus management after validation: one error focuses that field, two or more render and focus the summary.
Core Pattern
interface SummaryEntry { fieldId: string; label: string; action: string; }
export function renderSummary(entries: SummaryEntry[], root: HTMLElement): HTMLElement | null {
root.replaceChildren();
if (entries.length < 2) return null; // one problem goes straight to its field
const box = document.createElement('div');
box.className = 'error-summary';
// Programmatically focusable, but NOT a tab stop: -1 keeps it out of the
// sequence while letting focus() land on it.
box.tabIndex = -1;
box.id = 'error-summary';
const h = document.createElement('h2');
// The count goes in the heading because the heading is what a screen reader
// announces first when focus lands — scope before detail.
h.textContent = `There is a problem with ${entries.length} answers`;
const list = document.createElement('ul');
for (const e of entries) {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = `#${e.fieldId}`;
a.textContent = `${e.label} — ${e.action}`;
a.addEventListener('click', (ev) => {
ev.preventDefault();
const field = document.getElementById(e.fieldId);
if (!field) return;
// Reveal first: a collapsed section or an unreached step must open before
// focus can land, or focus() silently no-ops on a hidden element.
revealAncestors(field);
(field as HTMLElement).focus();
});
li.appendChild(a);
list.appendChild(li);
}
box.append(h, list);
root.appendChild(box);
// Focus AFTER insertion, in the same task: focusing a detached node does nothing.
box.focus();
return box;
}
Two lines carry most of the accessibility. tabIndex = -1 makes the container focusable without adding a tab stop — a summary in the tab sequence is an extra stop every reader passes through on every pass. And box.focus() after insertion is what announces it: the focus move causes the heading and the list to be read, which is why the container must not also be a live region.
Step-by-Step Walkthrough
-
Build entries in document order. The list should read in the same direction the form does.
-
Skip the summary for a single error. Focus that field instead; a list of one is a detour.
-
Put the count in the heading. It is the first thing announced when focus lands, and it is the answer to the reader’s first question.
-
Use real links. An
<a href="#id">is keyboard-operable, announced as a link, and works without JavaScript if the ids match. -
Reveal before focusing. Open collapsed sections and navigate to the owning wizard step first, or
focus()no-ops silently. -
Re-render on each attempt. Replace the container’s contents and re-focus, and change the heading text so the second announcement is distinguishable from the first.
Before the edge cases, the markup decisions worth defending in review:
Failure Modes and Edge Cases
1. Focusing a node that is not in the document yet
focus() on a detached element does nothing. Insert, then focus, in the same task — not in a setTimeout, which lets a render cycle move focus somewhere else first.
2. The second submit announces nothing
Re-focusing an element that already has focus does not re-announce in several screen readers. Changing the heading text between attempts gives them something new to read.
3. The link jumps under a sticky header
href="#id" scrolls the target flush to the viewport edge. scroll-margin-top on the field fixes it in one declaration, without any scroll arithmetic.
4. An entry for a field that is not rendered
A server error naming a field the form does not show still belongs in the summary — with no link, and with wording that says what the reader can do instead. Omitting it makes the count wrong and the failure invisible.
5. The summary is styled but not semantic
A <div> of <div>s with click handlers is not a list of links. Screen readers announce “list, 3 items” for a real list, which is part of the scope information the summary exists to give.
And the four ways a summary manages to be announced twice:
Verification Checklist
Common Pitfalls
- Rendering the summary but never focusing it. Sighted readers see it appear; keyboard and screen-reader users get no indication it exists, and continue from wherever they were. The focus move is what makes it a summary rather than a decoration.
- Focusing the heading instead of the container. Focus on an
h2announces the heading and stops. Focus on the container announces the heading and then the list, which is the scope information the reader wanted. - Building entries from
Object.keys(errors). Field names are not labels.billingAddress.postCodein a summary is a string the reader has never seen and cannot map to anything on screen. - Leaving the container in the DOM when empty. An always-present container that is empty most of the time is still a focus target and can still be reached by a stray focus call. Remove it, or render it only when there is something to say.
Related
- Error Summary and Messaging — where the summary fits in the error model
- Writing Error Messages That Tell the Reader What to Do — the copy inside each entry
- Moving Focus to the First Invalid Field — the single-error branch
Frequently Asked Questions
Why tabindex minus one rather than zero?
Because minus one makes the container focusable programmatically without inserting it into the tab sequence. With zero, every reader tabbing through the page stops on the summary container on every pass, including before any error exists if the container is always present. Minus one gives you the focus target you need and costs nobody a keystroke.
Should the summary be re-focused on every failed submit?
Yes, and the heading text should change too. Re-focusing an element that already has focus does not re-announce in several screen readers, so a reader who fixes one problem and introduces another can be left with no feedback at all. Rewriting the heading — even just the count — gives the reader something new to hear.
What about an error that has no field to link to?
Include it, without a link, and word it so the reader knows what to do — ‘Your session expired. Sign in again to continue.’ Leaving it out makes the count disagree with the list and hides the actual reason the submit failed, which is the failure mode the summary exists to prevent.