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"
The full lifecycle of a grid's data Chain of states: loading, then ready with rows, empty (never had data), no results (filters exclude all), error (request failed), and stale (refresh failed but old data shown). The full lifecycle of a grid's dataLoadingaria-busy; polite"Loading"Readyrows; polite countFirst useheading,explanation, CreateNo resultsmessage row; Clearfiltersresolvesnone yetfilters exclude allErroralert; Try againStalebanner; last updatedtimerefresh fails
Each state has its own message, politeness and action.

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 &amp; 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>
Message, politeness and action for each state Matrix of six states — loading, first use, no results, error, stale, partial — against the message, its politeness and the recovery action. Message, politeness and action for each stateMessagePolitenessActionLoadingLoading invoicesPolitenoneFirst useNo invoices yetPoliteCreate invoiceNo resultsNo invoices match…PoliteClear or remove filterErrorCould not load invoicesAssertiveTry againStaleData may be out of datePolite, onceRefresh nowPartialTotal: 2 of 3 regionsIn text onlyRetry missing source
Assertive is reserved for the one state that blocks the task.

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.

Writing a message that stands alone A message is built from three parts — the subject, the cause and the next step — then checked by reading it aloud out of context. Writing a message that stands aloneSubjectwhich table or data"No invoices…"Causefilters, failure or age"…match Status is Overdue."Next stepthe action available"Clear filters is available in thetable."Read it aloud alonewould it make sense in five minutes?if not, rewrite

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"
One generic empty component versus one per state Comparison: a single EmptyState with a variant prop drifts toward generic copy and shared politeness; separate components enforce required text, actions and the right announcement for each state. One generic empty component versus one per state✗ One EmptyState with variantsCopy drifts to "Nothing here"Error and empty share politenessRecovery action optionalFocus handling left to products✓ One component per stateRequired heading and explanation per stateError component owns its assertive alertRecovery action requiredFocus moves built in

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.

Permalink to "Related"

← Back to Accessible Data Tables & Grid Systems