Announcing Match Counts for In-Grid Search

Permalink to "Announcing Match Counts for In-Grid Search"

An in-grid search box that highlights matches gives sighted users an instant answer to “did it find anything?”. A screen reader user typing into the same field hears only their own keystrokes. The match count message is a single polite status update — “12 rows match ‘overdue’” — that closes that gap, and its absence is one of the most common findings when search is added to an existing grid.

This guide covers the count message for search that highlights or jumps between matches inside a grid, as opposed to a filter that removes rows. It belongs to table search and match highlighting.

Spec reference

Permalink to "Spec reference"

The governing criterion is SC 4.1.3 Status Messages (AA): a change in content that is not given focus — here, the number of matches — must be programmatically determinable through role or properties so it can be presented without moving focus. In practice that means a container with role="status" (implicit aria-live="polite" and aria-atomic="true") that already exists in the DOM when the text changes.

  • role="status" — polite, atomic. The whole sentence is re-read on change, which is what you want for “N matches”.
  • aria-live="polite" on a plain element — equivalent if you also set aria-atomic="true".
  • role="alert" — assertive. Not appropriate: a match count is not urgent, and an alert interrupts the user’s own typing echo.
  • SC 3.3.1 Error Identification does not apply to zero matches; zero results are a status, not an input error.
Lifecycle of one count message Typing in the search field resets a debounce timer; when typing pauses the grid computes matches, writes a sentence to the status region, and the screen reader speaks it after the keystroke echo. Lifecycle of one count messageKeystrokeuser types acharacterDebounce500 ms of quietCountrows and cellsmatchedStatus region"12 rows matchoverdue"resets timercomputewrite text
The status region exists from page load; only its text changes.

When to use — and when not to

Permalink to "When to use — and when not to"

Use a count announcement whenever a search box changes what is highlighted or where “Find next” will land, without changing which rows exist. The user needs the count to decide whether to keep typing, step through matches, or give up.

Do not use it as a substitute for focus movement when the user explicitly asks to go somewhere. If the user presses Enter to jump to the first match, move focus to that cell; the cell’s own name is the announcement, and a separate “1 of 12” can follow in the status region.

The common misapplication is announcing on every keystroke. Typing “overdue” produces seven status updates in under two seconds; NVDA and JAWS queue or drop them unpredictably, and the user hears a stutter of counts for queries they never meant to run.

Per-keystroke versus debounced announcements The left column lists what happens when every keystroke updates the status region; the right lists the behaviour with a debounce and one settled message. Per-keystroke versus debounced announcements✗ Per keystroke"84 matches", "40 matches", "12 matches" inone secondCounts compete with the keystroke echoStale counts may be the last thing spokenBraille display flickers through numbers✓ DebouncedOne message after typing pausesEcho finishes first, then the countThe spoken count matches the final queryBraille line shows one stable sentence

Annotated code example

Permalink to "Annotated code example"
<!-- Search field with a visible label (SC 1.3.1, 2.5.3) -->
<label for="grid-search">Search invoices</label>
<input id="grid-search" type="search" aria-describedby="grid-search-count"
       autocomplete="off">
<!-- Status region rendered on load, empty (SC 4.1.3). Visible text doubles as the description. -->
<p id="grid-search-count" role="status" class="search-count"></p>

<table aria-labelledby="invoices-caption"> ... </table>
const input = document.getElementById('grid-search');
const status = document.getElementById('grid-search-count');
let timer;

input.addEventListener('input', () => {
  clearTimeout(timer);
  timer = setTimeout(() => {
    const q = input.value.trim();
    const { rows, cells } = highlightMatches(q); // your highlighter returns counts
    // One complete sentence, query included (SC 4.1.3)
    status.textContent = q === ''
      ? ''                                                    // cleared: say nothing
      : rows === 0
        ? `No rows match “${q}”. Check spelling or clear the search.`
        : `${cells} ${cells === 1 ? 'match' : 'matches'} in ${rows} ${rows === 1 ? 'row' : 'rows'} for “${q}”.`;
  }, 500); // debounce so the count describes the final query
});

The visible paragraph and the announced text are the same node. That guarantees the two never disagree, and it gives sighted keyboard users and screen magnifier users the count without hunting for a highlight.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Event Expected announcement AT deviations
Typing pauses Keystroke echo, then “12 matches in 9 rows for ‘overdue’” JAWS may wait until the echo of the last character finishes; NVDA speaks immediately after
Query cleared with Escape Nothing (region emptied) VoiceOver on macOS sometimes reads “search field, empty” — expected
Zero matches “No rows match ‘overduee’. Check spelling…” TalkBack reads it after the keyboard closes on some Android versions
Field re-focused later Field name, then description with the last count Description read only if aria-describedby points at the status text
How each reader handles the debounced count A matrix of NVDA, JAWS, VoiceOver and TalkBack showing whether the count is spoken after typing pauses, whether rapid updates are dropped, and whether the description is read on refocus. How each reader handles the debounced countSpoken after pauseRapid updates withoutdebounceCount on refocusNVDA + Firefox/ChromeYesQueued, some droppedYes, via descriptionJAWS + ChromeYes, after echoOften only first and lastYesVoiceOver + SafariYesFrequently interruptedRead after a pauseTalkBack + ChromeAfter keyboard settlesMostly droppedYes
Plus means reliable, tilde means works with caveats.

Integration context

Permalink to "Integration context"

The count message is one half of in-grid search; the other half is making the matches themselves perceivable, covered in highlighting search matches with the mark element. Once users know matches exist, they need a way to reach them, which is find next and previous in data grids.

If several things on the page announce — a filter count, a sort confirmation, a background refresh — route them through one queue as described in announcement queues for competing messages so the match count is not swallowed.

Gotchas

Permalink to "Gotchas"

Creating the region on first search. A status element inserted at the same moment as its text is ignored by most screen readers. Render it empty on page load, as explained in creating live regions before content changes.

Identical consecutive messages. Searching “paid”, then "paid " (trailing space) produces the same sentence, and some readers skip text that did not change. Trim the query before comparing, and if you must re-announce, clear the region and set it again in the next animation frame.

Counting hidden columns. If a column is hidden through a column chooser, its matches should not be counted or the number will not match what the user can find. Count only rendered, visible cells.

Testing checklist

Permalink to "Testing checklist"

Design system notes

Permalink to "Design system notes"

Ship the search field, its status line and the debounce as one component rather than three, so product teams cannot wire the field without the announcement. Expose the message wording as a translatable template with plural rules — “1 match in 1 row” and “14 matches in 9 rows” need different forms in most languages — and keep the debounce delay as a documented token so it can be tuned once for every grid.

FAQ

Permalink to "FAQ"
Should the count say rows or cells?

Say both when they differ — “14 matches in 9 rows” — because users step through cells but scan by row. If your search only ever matches whole rows, “9 rows match” is enough.

Why not use aria-live="assertive" so users hear the count immediately?

Assertive messages interrupt the echo of the user’s own typing and can cut off the previous character. The count is useful but not urgent, which is exactly what polite is for.

How long should the debounce be?

Between 400 and 700 ms works for most users. Shorter and you announce partial queries; longer and users wonder whether the search ran. Make it a single constant you can tune after testing with screen reader users.

Permalink to "Related"

← Back to Table Search & Match Highlighting