Accessible Empty State for Filtered Tables
Permalink to "Accessible Empty State for Filtered Tables"A filtered table that matches nothing is one of the most common states in any data UI, and one of the least designed. The usual implementation removes the table entirely and shows a friendly illustration. For a sighted mouse user that is fine. For a screen reader user who was navigating the table, the table has simply vanished: the table navigation command reports “no tables”, focus may have fallen to the document body, and nothing explains why.
This guide covers the empty state that results from the user’s own filters — the “no results” case. First-use empty states, where there is no data yet, are a different problem covered in first-use empty states for data tables. Both belong to empty, error & no-results states.
Spec reference
Permalink to "Spec reference"- SC 4.1.3 Status Messages (AA) — “No rows match these filters” is a status message and must be announced without moving focus.
- SC 1.3.1 Info and Relationships (A) — keeping the table, caption and column headers tells users which columns would have contained results and that the table still exists.
- SC 3.3.3 Error Suggestion (AA) — zero results is not strictly an input error, but suggesting how to recover (remove a filter, check spelling) follows the same spirit and is what users need.
- SC 2.4.3 Focus Order (A) — if the control that had focus disappears, focus must move somewhere logical.
The HTML Living Standard allows a td with colspan spanning all columns; it remains a data cell under the column headers, which is acceptable because the message relates to every column.
When to use — and when not to
Permalink to "When to use — and when not to"Use an in-table empty message whenever filters, search or facets reduce the result set to zero. Keeping the structure makes the result legible: “Invoices, table, 2 rows” (header plus message) tells a screen reader user the table is still there.
Do not use it while data is still loading. “No results” shown during a slow request is a false statement that users act on. Show a loading state with aria-busy until the request resolves, then the empty state — see using aria-busy during progressive table loads.
The misapplication to name: replacing the whole table with an illustration and a heading. Besides losing the structure, it often moves the illustration’s heading into the document outline as an h2 that appears and disappears as filters change.
Annotated code example
Permalink to "Annotated code example"<p id="filter-status" role="status">No invoices match: Status is Overdue, Customer is Lund & Co.</p> <!-- SC 4.1.3 -->
<table aria-labelledby="inv-caption" aria-describedby="filter-status">
<caption id="inv-caption">Invoices</caption>
<thead>
<tr><th scope="col">Invoice</th><th scope="col">Customer</th>
<th scope="col">Status</th><th scope="col">Amount</th></tr>
</thead>
<tbody>
<tr class="empty-row">
<!-- One data cell spanning every column (SC 1.3.1) -->
<td colspan="4">
<p>No invoices match the current filters.</p>
<!-- Recovery in one step (SC 3.3.3 spirit) -->
<button type="button" id="clear-filters">Clear filters</button>
</td>
</tr>
</tbody>
</table>
clearButton.addEventListener('click', () => {
resetFilters();
status.textContent = `Filters cleared. Showing ${total} invoices.`;
// The button is about to be removed with the empty row: move focus first (SC 2.4.3)
document.getElementById('filter-field').focus();
});
In an interactive role="grid", the message row must still be navigable: give the cell role="gridcell" and make it the single focus stop, so arrow keys do not get stuck and Tab lands on the button.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected announcement | AT deviations |
|---|---|---|
| Filter produces zero rows | “No invoices match: Status is Overdue, Customer is Lund & Co.” | JAWS may read it only after the field’s own echo |
| T (next table) in browse mode | “Invoices, table, 2 rows, 4 columns” | VoiceOver counts only the body row |
| Arrow into the message cell | “Invoice, column 1 through 4, No invoices match…” | NVDA reports the span as “columns 1 through 4” |
| Activate Clear filters | “Filters cleared. Showing 240 invoices.” Focus in filter field | — |
Integration context
Permalink to "Integration context"The zero-count announcement uses the same region and wording rules as announcing filter result counts with aria-live; the empty state is simply the zero case of that message. If users removed filters one chip at a time, the chip pattern in applied filter chips and removal applies. When no results come from a failure rather than a filter, use announcing data load errors in grids instead — “no results” and “could not load” must never look the same.
Gotchas
Permalink to "Gotchas"aria-rowcount left at the old total. If the grid declares aria-rowcount="241", update it to 2 (header plus message) or remove it; otherwise screen readers report hundreds of rows in an empty table.
Message without the filters. “No results” alone forces users to go back and inspect every filter. Name the active filters in the message — that is the information they need to decide which to remove.
Illustrations with alt text. A decorative empty-state illustration should have alt="". An illustration with alt text such as “Person looking at an empty box” is read before the useful message.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Give the table component a built-in emptyMessage slot that renders the spanning row, rather than leaving product teams to swap the table for a different component. The slot should accept the filter summary and a recovery action, and the component should handle colspan, aria-rowcount and focus movement itself.
Framework notes
Permalink to "Framework notes"Component libraries handle the empty case differently, and most default to the wrong one:
- TanStack Table (React, Vue, Solid) is headless, so you render the body yourself. When
table.getRowModel().rows.length === 0and filters are active, render a singletrwithtd colSpan={table.getVisibleLeafColumns().length}— using the visible column count keeps the span correct after columns are hidden. - MUI X Data Grid shows its
noRowsOverlayornoResultsOverlayas an overlay outside the grid’s rows. Supply a customnoResultsOverlayslot with real text and a Clear filters button, and announce the count yourself; the overlay is not announced automatically. - Angular Material table offers
*matNoDataRow, which renders inside the table — the right structure. Give its cell the fullcolspanand include the filter summary.
{rows.length === 0 && hasFilters && (
<tr>
<td colSpan={table.getVisibleLeafColumns().length}> {/* spans what is actually visible */}
No invoices match: {describeFilters(columnFilters)}.
<button type="button" onClick={clearFilters}>Clear filters</button>
</td>
</tr>
)}
FAQ
Permalink to "FAQ"Should the empty message be inside the table or after it?
Inside, in a spanning row, is best for screen reader users navigating by table. Directly after the table, inside the same labelled region, is acceptable when the design needs more room — but then keep the header row visible.
Should zero results be announced assertively?
No. It is a normal outcome of filtering. A polite status message is correct; assertive would interrupt the user’s typing.
What about grids with role="grid"?
Make the message a single gridcell that receives focus, with the recovery button inside it reachable with Tab or Enter, so the grid’s arrow-key model has somewhere valid to land.
Related
Permalink to "Related"- First-use empty states — when there is no data yet
- Zero-results suggestions and recovery — helping users out of the empty state
- Announcing filter result counts — the message’s non-zero form