Error Summary
The form-level validation summary: a focusable panel above the form listing every problem, each one a link to the field that has it.
Preview
Renders nothing until it has errors. Set errors, then call
focus() — that is what tells the user something went wrong.
<esa-error-summary id="summary"></esa-error-summary>
<script>
summary.errors = [
{ field: 'project-name', message: 'Enter a project name.' },
{ field: 'pathway', message: 'Select a review pathway.' },
];
summary.focus(); // ← the announcement
</script> Why a summary, and why it takes focus
Inline error-text on each field is necessary and not sufficient. When the
user presses Submit their focus is on the button, usually at the bottom of the form.
Errors are added above it, off-screen, and nothing announces them: a screen reader is on
one element at a time and hears nothing about a mutation elsewhere; someone at high page
zoom sees only the region around the button; anyone on a long form is scrolled past the
fields that failed. All three get a form that appears to do nothing when pressed.
This is the W3C's advisory technique for both SC 3.3.1 (Error Identification) and SC 3.3.3 (Error Suggestion), and it is what GOV.UK ships on every form — including single-field ones, so the pattern is consistent rather than conditional.
It is deliberately not a live region, and adding role="alert"
would be a regression. Moving focus is a change of context that assistive technology
already surfaces, so SC 4.1.3 does not apply — that criterion exists for updates which
happen without taking focus. Focus is also more robust: an announcement is
transient and cannot be replayed, whereas a focused summary is still sitting there when
the user wants to re-read it.
The alternative scales badly. Turn on live-error for every field and a
six-error form fires six assertive announcements at once; assertive updates can clear
each other's queue, so the user hears some subset, in no guaranteed order, while their
focus is somewhere else. One summary, one focus move, one reading order — and it carries
what a per-field announcement cannot: how many problems there are, and a
route to each.
Wiring it to a form
Put novalidate on the form to suppress the browser's own error bubbles —
they are unstyleable, they do not respect page zoom, and Chrome dismisses them after
about five seconds, which can be while the user is still reading. Then intercept submit,
collect what failed, and set both the summary and each field's error-text.
<form id="intake" novalidate>
<esa-error-summary id="summary"></esa-error-summary>
<esa-text-field id="name" label="Project name" required></esa-text-field>
<esa-text-field id="email" label="Contact email" type="email"
autocomplete="email" required></esa-text-field>
<button type="submit">Save and continue</button>
</form>
<script>
intake.addEventListener('submit', (e) => {
e.preventDefault();
const failed = [];
for (const f of [name, email]) {
const bad = !f.checkValidity();
// Same wording in both places.
f.errorText = bad ? f.validationMessage : '';
if (bad) failed.push({ field: f.id, message: f.validationMessage });
}
summary.errors = failed;
summary.focus(); // no-op when failed is empty
if (!failed.length) intake.submit();
});
</script>
Submit the form above empty, then with a malformed email. Focus lands on the summary, and
each link puts you on the field it names — including the input inside that
component's shadow root, which the summary reaches by calling focus() on the
host rather than by an IDREF that could never cross the boundary.
API
| Prop | Type | Default | Description |
|---|---|---|---|
errors | EsaFieldError[] | [] | The problems to list. Each entry is { field, message }: `field` is the id of the HOST element to send the user to, `message` is what is wrong in the user's words. Omit `field` for a form-level error ("Your session expired") and it renders as plain text rather than a link. Renders nothing when empty, so it is safe to leave in the DOM. |
heading | string | 'There is a problem' | Heading text. The error count is appended for you — do not put a number here, or it will be the thing that disagrees with the list underneath it. |
heading-level | number | 2 | Which heading element to render, 2–6. A summary sits inside a form under whatever heading introduces it, so h2 is usually right — but heading order is a document-level concern the component cannot see, and skipping a level is its own failure (SC 1.3.1). Property: `headingLevel`. |
Accessibility
-
tabindex="-1"withrole="group"andaria-labelledbypointing at the heading — a bare<div>is name-prohibited, so without the role the container would announce nothing when it takes focus. -
The focus ring uses
:focus, not:focus-visible. This is one of the few places that is correct: the user was sent here by a failed submit rather than clicking, and:focus-visibleheuristics can decide not to paint — which would move a sighted keyboard user with no indication of where they went. - The count is inside the heading, so it is announced when focus lands. It is the single most useful thing to say at that moment, and a separate paragraph below would not be read.
-
Items are a real
<ul>, so the list role reinforces the count. - Links are underlined, not just coloured — the panel sits on a tinted danger ground where a colour shift alone would be the SC 1.4.1 failure.
-
Each link resolves its field via
getRootNode(), so a summary nested inside a spoke's own shadow root still finds fields in that same root. -
Pair it with, never instead of,
error-texton each invalid control. The summary says what failed; the inline message sits where the fixing happens. Word them the same.
Theming surface
Every public token this component reads, extracted from its source at build time,
split by blast radius: the hooks that belong to this component,
then the shared and system tokens it consumes alongside everything else. The
Resolves to column walks each token's real lineage —
component → semantic → primitive → raw value — so you
can see exactly where re-pointing it lands. A spoke re-skins by overriding these in
its theme-<slug>.css; component internals are never edited. No
hook for what you need? File it with /request-lego.
Type roles it names
This component does not set a size, weight, face or tracking of its own — it names a composite, and the composite carries all five. A composite is a class rather than a token, so none of it appears in the tables below; re-theme text by re-pointing the token family, not by editing the component.
| Role | Re-point | Reach |
|---|---|---|
.typography-label-md-strong |
| +3 more componentsesa-avatar, esa-empty-state, esa-header-nav |
Wired to this component
None. This component has no hooks of its own — it themes entirely through the shared tokens below, so every override here also moves its siblings.
Shared tokens it reads
Owned by a family or by the semantic layer. Still overridable — but scope the
override (esa-foo { --form-border-color: … }) unless you mean to move
everything that reads it. Reach says what else that is.
| Token | Reach | Resolves to |
|---|---|---|
--focus-ring-color | component focus ring group · +34 more | #3e9b4f via --color-border-default-focus → --color-grass-10 |
--focus-ring-offset | component focus ring group · +31 more | 2px |
--focus-ring-width | component focus ring group · +35 more | 2px via --border-width-focus → --border-width-200 |
--border-width-default | semantic every component | 1px via --border-width-100 |
--color-background-utility-danger-subtle | semantic every component | #fffcfc via --color-red-1 |
--color-border-utility-danger | semantic every component | #fdbdbe via --color-red-6 |
--color-content-default | semantic every component | #202020 via --color-gray-12 |
--color-content-utility-danger | semantic every component | #ce2c31 via --color-red-11 |
--radius-md | semantic every component | 0.5rem via --radius-200 |
--spacing-100 | primitive every component | 0.25rem |
--spacing-200 | primitive every component | 0.5rem |
--spacing-300 | primitive every component | 0.75rem |
--spacing-400 | primitive every component | 1rem |