Empty, Error & No-Results States
Permalink to "Empty, Error & No-Results States"Every data grid spends part of its life with no rows to show — before the first record exists, after a filter excludes everything, while a request is failing, or while it shows yesterday’s numbers because today’s refresh broke. These states are usually designed last and tested never. They are also where accessibility fails most quietly: the table vanishes from the accessibility tree, focus drops to the body, an error appears only as a red toast, or the screen says “No invoices yet” when in fact the server is down.
The users most affected are screen reader users, who cannot see that a table has been replaced by an illustration or that rows are greyed out; keyboard users, whose focus is lost when the button they pressed disappears with the row that held it; magnifier users, who are zoomed into the middle of a grid and miss banners at the top; and users with cognitive disabilities, for whom “Something went wrong” without a next step is a dead end. The failure mode this topic prevents is a user who cannot tell the difference between nothing exists, nothing matches, something broke and this is out of date — four situations that demand four different responses.
This topic belongs to accessible data tables & grid systems.
WCAG criteria in scope
Permalink to "WCAG criteria in scope"| Criterion | Level | Relevance to empty and error states |
|---|---|---|
| 1.1.1 Non-text Content | A | Empty-state illustrations are decorative; status icons need names or hidden duplicates |
| 1.3.1 Info and Relationships | A | Keep table structure; associate messages and stale markers with their data |
| 1.4.1 Use of Color | A | Errors and staleness need text, not just red or grey |
| 2.4.3 Focus Order | A | Focus moves logically when the focused control is removed |
| 2.4.6 Headings and Labels | AA | Messages and actions name the specific situation |
| 3.3.3 Error Suggestion | AA | Zero-result and error states suggest how to recover |
| 4.1.2 Name, Role, Value | A | Retry buttons expose busy state without losing focus |
| 4.1.3 Status Messages | AA | Empty, no-results, error and recovery messages announced without focus changes |
Prerequisites
Permalink to "Prerequisites"- ARIA live regions for dynamic data and the differences in role status, alert and log compared, since this topic uses both polite and assertive messages.
- Semantic HTML table construction, because several patterns rely on a spanning cell inside an otherwise normal table.
- Focus management in single-page apps, especially moving focus before removing the focused element.
- The loading half of the lifecycle, in progressive loading and skeleton states.
ARIA & HTML spec reference
Permalink to "ARIA & HTML spec reference"| Element / attribute | Valid values | When to apply | Common misuse |
|---|---|---|---|
td colspan |
Number of columns | Message rows in no-results and error states | Removing the table instead |
role="status" |
— | Empty, no-results, recovery and stale transitions | Creating it at the moment of the message |
role="alert" |
— | Failures that block the task | Routine counts and confirmations |
aria-busy |
true / false |
During load; cleared in finally |
Left true after an exception |
aria-disabled |
true |
Retry button while a request is in flight | disabled, which drops focus |
time datetime |
ISO 8601 | Last-updated timestamps | Relative text without an absolute time |
aria-describedby |
ID list | Tie a stale notice or filter summary to the table | Pointing at text that is never updated |
alt="" |
Empty | Decorative empty-state illustrations | Descriptive alt for decorative art |
Step-by-step implementation
Permalink to "Step-by-step implementation"Step 1 — Model the states explicitly (SC 1.3.1)
Permalink to "Step 1 — Model the states explicitly (SC 1.3.1)"Represent state as an enum, not as “rows is empty”. Most bugs in this area come from rows.length === 0 meaning three different things.
type GridState =
| { kind: 'loading' }
| { kind: 'ready'; rows: Row[] }
| { kind: 'first-use' }
| { kind: 'no-results'; filters: Filter[] }
| { kind: 'error'; message: string; retryable: boolean }
| { kind: 'stale'; rows: Row[]; lastUpdated: Date };
Step 2 — Keep the table for no-results and errors (SC 1.3.1)
Permalink to "Step 2 — Keep the table for no-results and errors (SC 1.3.1)"Render the caption and header row and put the message in a cell spanning every column, as in accessible empty state for filtered tables.
<tbody><tr><td colspan="4">No invoices match: Status is Overdue.</td></tr></tbody>
Step 3 — Announce with the right politeness (SC 4.1.3)
Permalink to "Step 3 — Announce with the right politeness (SC 4.1.3)"Polite for empty and no-results, assertive for blocking failures, as described in announcing data load errors in grids.
const announce = { 'no-results': polite, 'first-use': polite, error: assertive, stale: polite };
announce[state.kind](messageFor(state));
Step 4 — Offer specific recovery (SC 3.3.3, 2.4.6)
Permalink to "Step 4 — Offer specific recovery (SC 3.3.3, 2.4.6)"Each state gets its own action: Create, Clear filters, Remove one filter, Try again, Refresh now. See zero-results suggestions and recovery.
<button type="button">Remove <q>Customer is Lund & Co</q> (12 results)</button>
Step 5 — Keep focus stable (SC 2.4.3, 4.1.2)
Permalink to "Step 5 — Keep focus stable (SC 2.4.3, 4.1.2)"Use aria-disabled while retrying and move focus before removing the focused element, as in retry controls for failed data loads.
filterField.focus(); // first move focus…
renderRows(rows); // …then remove the row holding the button
Step 6 — Mark stale and partial data in text (SC 1.4.1)
Permalink to "Step 6 — Mark stale and partial data in text (SC 1.4.1)"Add a banner with a time element and per-row text markers, as in stale and partial data indicators.
<span class="stale-tag">Not refreshed since <time datetime="2026-10-02T09:42">09:42</time></span>
Keyboard interaction contract
Permalink to "Keyboard interaction contract"| Key | Action | Expected AT announcement | Failure indicator |
|---|---|---|---|
| T / table navigation | Reach the table in no-results or error state | “Invoices, table, 2 rows” | “No tables” — table was removed |
| Tab | Reach the recovery action in the message row | “Clear filters, button” | Action unreachable or unnamed |
| Enter on Clear filters | Restore rows | “Filters cleared. Showing 240 invoices.” | Silence; focus on body |
| Enter on Try again | Retry | “Retrying…, unavailable” then outcome | Focus lost; double request |
| Enter on a removal suggestion | Remove one filter | “Removed filter. Showing 12 invoices.” | Count differs from prediction |
Screen reader compatibility matrix
Permalink to "Screen reader compatibility matrix"| AT + browser | Spanning message cell | Alert on failure | Notes |
|---|---|---|---|
| NVDA + Firefox | “column 1 through 4” | Interrupts current speech | Reliable |
| NVDA + Chrome | Same | Interrupts | Clear the region before repeating a message |
| JAWS + Chrome | Reads cell text | Queued briefly | Status after focus moves may be clipped |
| VoiceOver + Safari | Reads text, span not reported | Waits for current utterance | Add role="list" to unstyled recovery lists |
| TalkBack + Chrome | Reads text | Announced | Test with keyboard dismissed |
| Narrator + Edge | Reads text | Announced | Scan mode off inside grids |
Edge cases & failure modes
Permalink to "Edge cases & failure modes"1. “No results” during loading
Permalink to "1. “No results” during loading"Diagnosis: the empty state renders while the request is still in flight. Fix: render the loading state until the request settles; never derive “empty” from an unresolved promise.
2. Error converted to emptiness
Permalink to "2. Error converted to emptiness"Diagnosis: a catch returns [], so failure renders as “No invoices yet”. Fix: model failure as its own state and test it by blocking the API in end-to-end tests.
3. Focus lost with the message row
Permalink to "3. Focus lost with the message row"Diagnosis: activating Clear filters removes the row containing the button. Fix: move focus to the filter field or first row before re-rendering.
4. Stale data in grey only
Permalink to "4. Stale data in grey only"Diagnosis: opacity marks old rows, failing contrast and colour-only rules. Fix: text markers and a banner with a timestamp.
5. Repeated failure not announced
Permalink to "5. Repeated failure not announced"Diagnosis: the alert region already contains the same text. Fix: clear it, then set the message in the next frame.
Accessible empty state for filtered tables
Permalink to "Accessible empty state for filtered tables"The no-results case keeps the table so users understand that their filters, not the data, produced the empty grid. A spanning message row names the active filters and offers Clear filters; the status region announces the zero count. See accessible empty state for filtered tables.
<td colspan="4">No invoices match: Status is Overdue. <button type="button">Clear filters</button></td>
Announcing data load errors in grids
Permalink to "Announcing data load errors in grids"Failure gets an assertive message, an error row in place of the data, and wording that says what failed and whether anything shown is old. See announcing data load errors in grids.
alertEl.textContent = 'Could not load invoices. Try again is available in the table.';
Retry controls for failed data loads
Permalink to "Retry controls for failed data loads"The retry button stays focused and busy with aria-disabled, guards against double activation, and moves focus only when rows exist to receive it. See retry controls for failed data loads.
retry.setAttribute('aria-disabled', 'true'); retry.textContent = 'Retrying…';
Stale and partial data indicators
Permalink to "Stale and partial data indicators"When old data stays on screen, a banner with a timestamp, per-row text markers and “partial” labels on totals tell everyone what can be trusted. Announce transitions only. See stale and partial data indicators.
<th scope="row">Total <span>(partial: 2 of 3 regions)</span></th>
First-use empty states for data tables
Permalink to "First-use empty states for data tables"Before any data exists, replace the table with a headed explanation and a specific action, and keep illustrations decorative. See first-use empty states for data tables.
<h2 id="empty-heading">No invoices yet</h2> <a href="/invoices/new">Create invoice</a>
Zero-results suggestions and recovery
Permalink to "Zero-results suggestions and recovery"Per-filter removal buttons with predicted counts, spelling suggestions and a Clear all action turn a dead end into a next step. See zero-results suggestions and recovery.
<button type="button">Remove <q>Status is Overdue</q> (3 results)</button>
Cross-cutting concerns
Permalink to "Cross-cutting concerns"Several problems span every state in this topic and are worth solving once:
One status region, many messages. Loading, no-results, recovery and stale transitions are all polite messages, often triggered within a second of each other — a filter change starts a load, which resolves to no results, while a background refresh fails. Route them through a single queue that drops superseded messages (“Loading” is irrelevant once “No results” is known), as described in announcement queues for competing messages.
Virtualized and paginated grids. A virtualized grid has no natural place for a message row; render it as the only row with aria-rowindex="2" and update aria-rowcount. A paginated grid needs to say whether “no results” applies to this page or to the whole result set — almost always the latter.
Wording. Messages are read out of context, possibly minutes after they appear. Each must stand alone: name the table (“No invoices match…”), name the cause (“Status is Overdue”), and name the next step. “No results” or “Error” alone fails all three.
Localisation and plurals. Counts in messages need plural rules; “1 results” is a small embarrassment in English and a grammatical error in many other languages. Use Intl.PluralRules or your i18n library’s plural support.
Reduced motion. Empty-state illustrations and error shakes should respect prefers-reduced-motion; a shaking error row is decoration, not information.
Who is affected, and how
Permalink to "Who is affected, and how"| User group | Typical failure | What fixes it |
|---|---|---|
| Screen reader users | Table vanishes; error never announced | Keep structure; status and alert messages |
| Keyboard users | Focus lost when the message row is replaced | Move focus before re-rendering |
| Magnifier users | Banner at the top of the page is off-screen | Messages inside the table, where they are looking |
| Users with cognitive disabilities | “Something went wrong” with no next step | Plain cause and a specific action |
| Forced-colours users | Red or grey states disappear | Text markers and system colours |
Design system integration
Permalink to "Design system integration"Ship FirstUseState, NoResultsState, ErrorState, StaleBanner and a table messageRow slot. Make heading, explanation and action required props. Document each component against its criteria — 4.1.3 for announcements, 2.4.3 for focus, 1.4.1 for stale markers — and include each state in the component’s visual regression and axe tests so they cannot silently regress.
Measuring whether it works
Permalink to "Measuring whether it works"Two numbers tell you whether these states are accessible in practice. The first is time to recovery: how long a keyboard or screen reader user takes to get from a zero-results or error state back to useful rows. In moderated sessions, a well-built state takes a few seconds — hear the message, Tab once, press Enter. A poorly built one can take minutes, or end with the user reloading the page. The second is false emptiness: how often the grid tells users there is no data when there is. Instrument the error path in production and count how often the empty state renders while the last request failed; the target is zero.
Testing checklist
Permalink to "Testing checklist"Automated
Permalink to "Automated"Keyboard
Permalink to "Keyboard"Screen reader
Permalink to "Screen reader"FAQ
Permalink to "FAQ"Can I reuse the same component for empty and error states?
You can share layout, but keep the state, wording and announcement separate. Shared components are how failure ends up saying “No data”.
Should the table disappear in a first-use state?
Yes — there is no data and no filters, so a sentence explaining what will appear is more useful than an empty header row. In no-results and error states, keep the table.
Is a toast enough for a failed refresh?
No. A toast disappears and is often missed. Use a persistent banner near the grid and, if the data shown is old, stale markers.
How do I test these states regularly?
Add fixtures for each state to your component stories and end-to-end suites, and include them in your axe scans of grid states with Playwright.
Related
Permalink to "Related"- Table search & highlighting — the zero-match case for find
- Accessible filtering & faceted search — the filters that produce no results
- Progressive loading & skeleton states — the loading half of the lifecycle
- Notifications & toasts for data operations — messages beyond the grid
- Screen reader announcement strategies — keeping messages from colliding