Lesson 19 / 25
Empty and Error States
The role, priority, and focus specification of the empty state and the error state; measuring the recovery path by key count, the effect of the error summary on discovery cost, and a completeness check of the state specification.
Contents
The skeleton rested on the assumption that content would arrive. Once loading ends, two situations remain where that assumption does not hold: no records arriving at all, and the request failing. The two look similar on screen — an empty area and a sentence — but tell the user entirely different things.
The Loading and Empty States lesson separated three causes of emptiness, and the Error and Warning States lesson classified errors by recoverability. This lesson writes the specification counterpart of those distinctions: which role, which announcement priority, where focus goes, and how many keys the recovery path takes.
What It Solves, When Not to Use It
The empty state and the error state are two separate patterns, and confusing them is the most expensive defect.
An empty state is a screen where the system is working correctly but there is no content to show. It does not belong to the error class; it belongs to the information class. A warning color, a warning icon, and an assertive announcement are not used.
An error state is a screen where something did not go as planned. The user has to do something, and what to do is written on screen.
The distinction is tested with a single question: does the system stay in a wrong state if the user does nothing? If the answer is no, it is an empty state. A search with no results is an empty state; a borrow request that could not be sent is an error.
Situations where it should not be used:
- Presenting an empty state as an error. Showing a red warning when the filter returns no records tells the user the system is broken.
- Presenting an error as an empty state. Writing “No records found” when a request has failed makes existing records look like they do not exist; the user gives up searching.
- Putting either one in a modal dialog. The empty state is the page itself; a modal dialog turns it into an obstacle the user has to dismiss.
Native Element First, ARIA Second
| State | Role | Priority | Focus |
|---|---|---|---|
| Empty state (all three kinds) | role="status" |
polite | Stays in place |
| Field validation error | Error summary role="alert" |
assertive | Moves to the summary |
| Request error | role="alert" |
assertive | Moves to the message container |
| Network disconnection | role="status" |
polite | Stays in place |
Three rules produce this table.
The empty state does not steal focus. The user might still be typing into the filter field; moving focus to the list when the result count changes interrupts typing. The announcement is made from a polite region.
Network disconnection is polite, an error is assertive. A dropped network is not the result of something the user did, and it corrects itself once the connection returns; it is not a notification that stops the user’s work.
The error summary exists in the document beforehand. The accessible error presentation rule from the Forms and Identity topic applies here: the summary container is placed empty, filled in once submission fails, and focus is moved there.
The empty state also needs a heading. The heading lets a screen reader user jump to this section from the heading plane; an empty state without a heading is invisible on that plane.
The Recovery Path Is Measurable
The most concrete side of the specification is the length of the path from the error to the fix. The focus ring is a known sequence; the key count can be counted. The script below measures a membership form submission with two invalid fields through three separate focus strategies, then runs the specification of nine states through a four-field check.
// recovery.mjs — measuring the recovery path by key count and checking the state specification // --- (a) Length of the recovery path ------------------------------------------ // Focus ring of the page holding the membership form (in document order). const PAGE = [ "skip-link", "logo", "nav-catalog", "nav-account", "search-field", "form-member-no", "form-name", "form-email", "form-phone", "form-address", "form-return-date", "form-note", "form-submit", "footer-help", "footer-contact", "footer-privacy", ]; // Tab and shift+tab cost the same; the distance between two elements is the shortest path in the ring. function step(ring, start, target) { const i = ring.indexOf(start), j = ring.indexOf(target); if (i < 0 || j < 0) throw new Error(`not in ring: ${i < 0 ? start : target}`); return Math.min((j - i + ring.length) % ring.length, (i - j + ring.length) % ring.length); } // A strategy: a starting focus and a sequence of targets to visit. // A target with link: true is reached with a single key (enter). function path(name, ring, start, targets) { let position = start, total = 0; const trace = []; for (const h of targets) { const n = h.link ? 1 : step(ring, position, h.target); total += n; trace.push(`${h.target}:${n}${h.link ? "*" : ""}`); position = h.target; } return { name, total, trace: trace.join(" ") }; } const A = path("no summary, focus stays", PAGE, "form-submit", [ { target: "form-email" }, { target: "form-return-date" }, { target: "form-submit" }, ]); // The error summary is added to the form; focus moves to the summary, the summary's link leads to the field. const RING_B = [...PAGE]; RING_B.splice(RING_B.indexOf("form-member-no"), 0, "error-summary", "summary-email", "summary-return-date"); const B = path("error summary + focus to summary", RING_B, "error-summary", [ { target: "summary-email" }, { target: "form-email", link: true }, { target: "form-return-date" }, { target: "form-submit" }, ]); const C = path("focus to first invalid field", PAGE, "form-email", [ { target: "form-email" }, { target: "form-return-date" }, { target: "form-submit" }, ]); // Discovery: how many fields must be visited to learn how many errors there are? const FIELD_COUNT = PAGE.filter((x) => x.startsWith("form-") && x !== "form-submit").length; const DISCOVERY = { [A.name]: FIELD_COUNT, [B.name]: 0, [C.name]: FIELD_COUNT - 1 }; console.log(`form field count: ${FIELD_COUNT}, invalid fields: form-email and form-return-date`); console.log("\nstrategy keys discovery trace (* = direct via link)"); for (const s of [A, B, C]) console.log(`${s.name.padEnd(34)} ${String(s.total).padStart(4)} ${String(DISCOVERY[s.name]).padStart(10)} ${s.trace}`); // --- (b) Checking the state specification -------------------------------------- // Each state must define four fields: role, announcement priority, focus target, recovery action. const STATE = [ { name: "first-use emptiness", role: "status", priority: "polite", focus: "in place", recovery: "description pointing to the search field" }, { name: "no-result search", role: "status", priority: "polite", focus: "in place", recovery: "clearing filters one by one" }, { name: "cleared list", role: "status", priority: "polite", focus: "in place", recovery: null }, { name: "field validation error", role: "alert", priority: "assertive", focus: "error summary", recovery: "link to the field plus a correction suggestion" }, { name: "server error", role: "alert", priority: "assertive", focus: "error message", recovery: "retry button" }, { name: "network disconnection", role: "status", priority: "polite", focus: "in place", recovery: "retry once the connection returns" }, { name: "authorization error", role: "alert", priority: "assertive", focus: null, recovery: "return to the sign-in page" }, { name: "record not found", role: null, priority: null, focus: "heading", recovery: "return to catalog search" }, { name: "rate limit exceeded", role: "alert", priority: "assertive", focus: "error message", recovery: null }, ]; const REQUIRED = ["role", "priority", "focus", "recovery"]; // "cleared list" is a success; no recovery action is expected. const EXEMPT = { "cleared list": ["recovery"] }; console.log("\nstate role priority focus recovery missing"); let missingTotal = 0; for (const d of STATE) { const missing = REQUIRED.filter((a) => d[a] === null && !(EXEMPT[d.name] ?? []).includes(a)); missingTotal += missing.length; console.log( `${d.name.padEnd(27)} ${(d.role ?? "—").padEnd(8)} ${(d.priority ?? "—").padEnd(10)} ${(d.focus ?? "—").padEnd(15)} ` + `${(d.recovery ? "yes" : "—").padEnd(9)} ${missing.length ? missing.join(",") : "none"}`, ); } console.log(`\ntotal missing fields: ${missingTotal} / ${STATE.length * REQUIRED.length}`);
form field count: 7, invalid fields: form-email and form-return-date strategy keys discovery trace (* = direct via link) no summary, focus stays 10 7 form-email:5 form-return-date:3 form-submit:2 error summary + focus to summary 7 0 summary-email:1 form-email:1* form-return-date:3 form-submit:2 focus to first invalid field 5 6 form-email:0 form-return-date:3 form-submit:2 state role priority focus recovery missing first-use emptiness status polite in place yes none no-result search status polite in place yes none cleared list status polite in place — none field validation error alert assertive error summary yes none server error alert assertive error message yes none network disconnection status polite in place yes none authorization error alert assertive — yes focus record not found — — heading yes role,priority rate limit exceeded alert assertive error message — recovery total missing fields: 4 / 36
The first table shows why deciding with a single number is wrong.
The no-summary setup asks for ten keys, and the discovery column gives the real cost: the user has to visit all seven fields to learn how many errors there are. Because focus stays on the submit button when submission fails, reaching the first invalid field takes five shift+tab presses.
Moving focus directly to the first invalid field is the cheapest path, at five keys. But the discovery column says six: the user learns that the field they are focused on is invalid, and does not learn that a second error exists. They find the second error only by continuing through the form, or do not find it at all and press submit a second time.
The error summary asks for seven keys — two more than the direct move — but brings the discovery cost down to zero. The summary states how many errors there are in a single sentence and gives a link to each one. The two extra keys are the price of answering “how many errors are there”, and it is cheap.
This measurement justifies a rule: a summary when there are multiple errors, a direct move when there is a single error. With one error, the summary has no discovery gain, and the two keys go to waste.
The second table checks the specification itself. Four of the nine states’ thirty-six fields are missing, and they are three distinct kinds of defect:
- The authorization error has no defined focus target. The message appears on screen but focus stays on the form; the user never hears the message.
- The record-not-found state has no defined role or priority. There is a heading on screen, but no announcement. This state is really a page transition, and focusing the heading is the right decision; what is missing is also defining it as a status announcement.
- The rate-limit-exceeded state has no defined recovery action. The user is not told what to do; without a path written such as “try again shortly”, the error is a dead end.
In the “cleared list” row, the recovery field is empty but is not counted as missing, because that state is a success and there is nothing to recover from. The check’s exemption list turns this distinction into part of the specification.
Measurable Constraints
3.3.1 Error Identification. An invalid field must be indicated with text; a red border alone is not enough. This is the form counterpart of the 1.4.1 Use of Color criterion.
3.3.3 Error Suggestion. If it is known how to fix the error, it must be written. “Invalid date” is an identification, not a suggestion; “The return date must be after today” does both at once.
3.3.4 Error Prevention. For irreversible operations, submission must be reversible, checkable, or confirmable. The confirmation pattern from the Modal Dialogs lesson is the counterpart of this criterion.
4.1.3 Status Messages. The empty state’s result count must be announced without moving focus.
2.4.6 Headings and Labels. The empty state’s heading must describe what the content is; a heading of “No records match the filters” works on the heading plane, where “No results” does not.
1.4.11 Non-Text Contrast. The invalid field’s border must meet at least a 3:1 ratio against the surrounding surface; the error color must be distinguishable at the border, not only in the text.
Common Mistakes and How to Recognize Them
Focus stays in place after submission. How to recognize it: submit the form with errors and press the tab key once; if focus does not go to the start of the form or to the summary, there is no strategy.
The error summary is added afterward. The announcement works sometimes and not other times. How to recognize it: check whether the summary container is in the document when the page is first built.
The empty state is announced with an assertive priority. The user’s reading is
interrupted every time the filter changes. How to recognize it: check the empty-state
region’s role; if it is role="alert", this is wrong.
The error message is not bound to the field. The message appears on screen but is not
read when the field is focused. How to recognize it: check whether the field’s
aria-describedby binding points to the message’s id.
The empty state has no heading. How to recognize it: pull up the heading plane and check whether the empty state appears on it.
Summary
- An empty state is a result of the system working correctly and belongs to the information class; an error state requires an action from the user, and that action is written on screen.
- The distinction is tested with a single question: does the system stay in a wrong state if the user does nothing?
- The empty state is announced from a polite region and does not steal focus; a field validation error is announced as assertive and focus moves to the error summary.
- The recovery path is measured by key count: the no-summary setup produces ten keys and a seven-field discovery, the direct move produces five keys and a six-field discovery, and the error summary produces seven keys and zero discovery.
- A summary is chosen when there are multiple errors, a direct move when there is a single error; the summary’s two extra keys are the price of answering “how many errors are there”.
- A state’s specification must carry all four fields — role, priority, focus target, and recovery action; the check defines the missing field and the exemption together.
Next Step
Empty and error states were notifications covering an entire screen. The same information is often carried on a much smaller surface: a small label next to a row, a colored dot next to a number, a one-word badge reporting a record’s status. These small surfaces harbor two traps together — carrying information in color alone, and writing only a number into the accessible name. The next lesson writes the specification for badges and tags, computes that color alone is not distinguishing enough through contrast and a color-blindness projection, and shows how to check for a second channel.
To keep your progress and take notes, Log in
My notes
Log in to take notes.