---
title: 'Empty and Error States'
source: 'https://academia.sh/en/courses/accessible-patterns/empty-and-error-states'
course: 'Accessible Component Patterns'
language: en
updated: '2026-08-19T05:19:52+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.
