Announcing Filter Result Counts With aria-live
Permalink to "Announcing Filter Result Counts With aria-live"A filter that silently rewrites the result set is a control that appears, to a screen reader user, to do nothing at all. The rows change, the count in the corner ticks down, the chips appear — and none of that reaches assistive technology unless a status message says so. This page covers the message itself: the debounce that keeps it to one announcement per settled query, the copy that makes it actionable, and the double-announcement bug that comes from having two live regions where one was intended.
It is the announcement detail beneath accessible filtering and faceted search, and it uses the region mechanics from ARIA live regions for dynamic data.
Spec reference
Permalink to "Spec reference"SC 4.1.3 Status Messages (Level AA) requires that a status message which does not receive focus is programmatically determinable through role or properties. A filter result count is the textbook case: content changed, focus stayed in the filter input, and the user needs to know the outcome.
The container is role="status", which carries an implicit aria-live="polite" and aria-atomic="true". Polite is correct here without exception — a filter count is never urgent enough to interrupt whatever the reader is currently saying, and an assertive filter region makes typing impossible because every announcement cuts off the previous one.
The region must exist before the first message is written. A region created at the same moment as its text is frequently missed entirely, most reliably on VoiceOver.
When to use — and when not to
Permalink to "When to use — and when not to"Announce on every settled change to the result set: a facet toggled, a query typed, a filter cleared, a saved filter set restored on load.
Do not announce on keystroke. Do not announce while the query is in flight — one “Loading results” message covers that, and only if the query is slow enough to need it. And do not announce on a re-render that did not change the result set, which is the most common source of phantom repeat announcements in React and Vue applications.
The misapplication to avoid is putting aria-live on the results container. It announces every row that enters the DOM, which on a filtered table means the entire result set is read aloud after every keystroke.
Annotated code example
Permalink to "Annotated code example"<!-- SC 4.1.3: present at page load, empty, so AT has registered the region -->
<p id="filter-status" role="status" class="visually-hidden"></p>
<!-- The visible counterpart, rendered from the same value -->
<p class="result-count">41 of 587 rows</p>
// SC 4.1.3: one announcement per SETTLED query — never one per keystroke
const status = document.getElementById('filter-status');
let debounce = null;
function onFilterChange() {
clearTimeout(debounce);
debounce = setTimeout(runQuery, 350); // 300-500 ms reads best
}
async function runQuery() {
const rows = await query(currentFilters());
renderRows(rows);
renderVisibleCount(rows.length, total); // same value as the message
status.textContent = ''; // force re-announce of identical text
requestAnimationFrame(() => {
status.textContent = summarise(rows.length, total, currentFilters());
});
}
function summarise(count, total, filters) {
if (count === 0) {
return `No rows match. Filters: ${describe(filters)}. ` +
`Remove a filter to widen the search.`;
}
if (!filters.length) return `Filter cleared. All ${total} rows shown.`;
return `${count} of ${total} rows match. Filters: ${describe(filters)}.`;
}
summarise is the whole design of the feature in one function. It names the count, anchors it against the total so the user knows whether 41 is a lot or a little, and lists the filters that produced it so they know what to change. The zero case gets its own branch because it is the state where a user is most likely to think the interface is broken.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Assistive technology | Behaviour | Deviation to plan for |
|---|---|---|
| NVDA + Firefox | Announces promptly after the debounce settles | Reads twice if a second live region exists |
| JAWS + Chrome | Announces the status text | Long filter lists may be cut at a sentence boundary |
| VoiceOver + Safari | Announces after a short delay | Drops the message if written in the same frame as the row swap |
| TalkBack + Chrome | Announces the status text | Counts rendered outside labels are not announced with their controls |
Integration context
Permalink to "Integration context"The count message is one of several things that must agree: the visible count, the applied-filter chips and the announced sentence. Rendering all three from a single state object is the only reliable way to keep them consistent — the classic bug is a visible count that updates on render and a hidden message that updates on a stale closure, so the announced number is one query behind.
Where filtering also resets pagination, combine the two facts into one message rather than firing two. Pagination and result-set navigation covers the page half.
Gotchas
Permalink to "Gotchas"Two live regions. Audit the page. One status region, one alert region, nothing else.
The message lags by one query. A debounced callback capturing a stale filter object announces the previous state. Compose the message inside the same function that read the results, not from state read later.
Zero announced as a load. An empty result set that announces nothing is indistinguishable from a query still running. Always announce the zero case, and say what would widen the search.
Announcements during a bulk clear. Clearing five filters at once should announce once, not five times. Batch the state change, then run one query and one message.
FAQ
Permalink to "FAQ"What debounce window works best for filter announcements?
Between 300 and 500 milliseconds. Below 250 ms a fast typist produces several announcements per word and the screen reader never finishes a sentence. Above about 800 ms the announcement noticeably lags the visible table, which is confusing for anyone using magnification alongside speech. Measure with a real keyboard rather than a synthetic test — typing rhythm matters more than the number.
Should the count be announced when it has not changed?
Only if the filters changed. If the user adds a filter and the count stays at 41, announce it anyway — the filter set is different and they need confirmation the action took effect. Use an empty-then-write so the identical text is re-announced. What you should not do is re-announce on an unrelated re-render.
Does the count belong in a visible element too?
Yes, and it should be the same string. A visible “41 of 587 rows” line helps sighted keyboard users and anyone using magnification, and rendering both from one value keeps the hidden message from drifting out of sync — the classic bug where the visible count is right and the announced one is one query behind.
Related
Permalink to "Related"- Accessible filtering & faceted search — the parent guide and the control markup
- Clearing filters without losing keyboard focus — the focus half of a filter change
- Choosing between polite and assertive aria-live regions — why filtering is always polite