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.

A filtered table with no matching rows Grid mock: the header row with Invoice, Customer, Status and Amount stays visible; the body contains one full-width message cell saying no invoices match Overdue and Lund & Co, with a Clear filters action. A filtered table with no matching rowsInvoiceCustomerStatusAmountNo invoices match:…Clear filtersThe message cell spans all four columns; the Clear filters button sits in the message or the toolbar.
The caption, headers and a single message cell remain; the table never disappears.

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.

Removing the table versus keeping it Comparison of replacing the table with an illustration against keeping the table with a message row. Removing the table versus keeping it✗ Table replaced by an illustration"No tables" from the table-navigation keyColumn context lostFocus may drop to the bodyHeading appears and disappears in the outline✓ Table kept with a message row"Invoices, table, 2 rows"Column headers still show what was searchedFocus stays in the filter field or moves toClear filtersOutline stable

Annotated code example

Permalink to "Annotated code example"
<p id="filter-status" role="status">No invoices match: Status is Overdue, Customer is Lund &amp; 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 —
Clearing filters from the empty state Sequence: the user activates Clear filters; the app resets filters, moves focus to the filter field before the button is removed, re-renders rows, and then writes a status message. Clearing filters from the empty stateUserEmpty rowFilter fieldStatusactivate Clear filtersfocus moved firstrows re-render; message rowremoved"Filters cleared. Showing240 invoices."
Focus moves before the row holding the button is removed.

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 === 0 and filters are active, render a single tr with td colSpan={table.getVisibleLeafColumns().length} — using the visible column count keeps the span correct after columns are hidden.
  • MUI X Data Grid shows its noRowsOverlay or noResultsOverlay as an overlay outside the grid’s rows. Supply a custom noResultsOverlay slot 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 full colspan and 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.

Permalink to "Related"

← Back to Empty, Error & No-Results States