Announcing Search Result Counts to Screen Readers
When search results replace themselves without a page load, sighted users see the change and screen reader users get nothing. The fix is a live region — and the reason this deserves its own guide is that a naive live region makes things worse, firing on every keystroke until the user cannot hear their own typing. Getting it right is a matter of four decisions: which region, when to update it, what the sentence says, and what must never go in it.
It implements one piece of Accessible Search & Filtering in React, using the live-region machinery from Dynamic Content & State Announcements.
WCAG Success Criteria Addressed:
4.1.3 Status Messages3.2.2 On Input2.2.2 Pause, Stop, Hide
Prerequisites
- A results region that updates client-side (search, filters, sorting — anything that swaps content without a navigation).
- Somewhere to render a persistent status element, ideally in a layout component so it exists on first paint.
- A basic understanding of
aria-live; the live regions guide covers the mechanics this page assumes.
Choose the Region Once, and Render It Always
A live region only announces changes that happen while it is in the accessibility tree. Adding the region and its message in the same render is the classic reason an announcement never fires.
Implementation Guidelines:
- Render the status element unconditionally, with an empty string as its initial content, and change only the text.
- Use
role="status"(which impliesaria-live="polite"andaria-atomic="true") rather than assembling the attributes by hand. - Keep it visually hidden with a clip-based utility, never
display: none— a display-none region is not in the accessibility tree and never announces. - Put exactly one results-status region on the page. Two regions competing to describe the same event produce doubled announcements.
// Always present, empty until there is something worth saying.
export function ResultsStatus({ message }: { message: string }) {
return (
<p role="status" className="visually-hidden">
{message}
</p>
);
}
/* Visually hidden but present in the accessibility tree. */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
Update It When the Search Settles
The announcement should describe a finished state, not a transition. That means updating on the response, not on the keystroke — and only after the user has stopped typing.
Implementation Guidelines:
- Debounce by 300–500 ms. Below 300 ms you announce intermediate states; above 600 ms it feels like the app forgot.
- Announce only when the result actually changes. If the count and query are identical to the last announcement, skip the update entirely.
- Suppress the "loading" message unless the wait exceeds roughly 500 ms, so fast responses produce one message instead of two.
- Announce filter changes through the same region. One event stream, one voice.
'use client';
import { useEffect, useRef, useState } from 'react';
export function useResultAnnouncement(query: string, count: number | null, pending: boolean) {
const [message, setMessage] = useState('');
const last = useRef('');
useEffect(() => {
if (pending || count === null) return;
const next = count === 0
? `No results for ${query}.`
: `${count} ${count === 1 ? 'result' : 'results'} for ${query}.`;
// A11y rationale: 4.1.3 Status Messages — repeating an identical message
// adds noise without information, so only genuine changes are announced.
if (next === last.current) return;
const timer = window.setTimeout(() => {
last.current = next;
setMessage(next);
}, 400);
return () => window.clearTimeout(timer);
}, [query, count, pending]);
return message;
}
Word the Sentence Like a Human
"18 results" is technically an announcement and practically useless: it does not say what was searched, whether filters are active, or what the user can do next.
Implementation Guidelines:
- Lead with the count, because it is the answer to the question the user asked.
- Include the query verbatim. Hearing their own typo back is how a screen reader user notices it.
- Name active filters when they materially change the count, and keep the phrasing stable so repeated announcements sound the same.
- Handle singular and plural properly, and give the zero case a recovery hint.
- Keep it to one sentence. Live regions are read in full, and a paragraph is a punishment.
Do Not Move Focus
The tempting "helpful" step — moving focus to the results heading — is the one thing to avoid. The user did not ask to be relocated, and on a slow connection focus jumps while they are still typing.
Implementation Guidelines:
- Announce through the live region and leave focus exactly where it is.
- If a jump to results is genuinely useful, offer it as an explicit control: a "Skip to results" link that the user activates.
- When results are reached by an explicit action (submitting the form with a button), moving focus to the results heading is defensible — the action was deliberate. Even then, say what happened.
- Never focus an element that is about to be replaced by the next render; focus lands on a detached node and collapses to
<body>.
How to Verify
- Count the announcements. Type a ten-character query with a screen reader running. You should hear exactly one message, after you stop.
- Type-through test. Type continuously for several seconds. Your own keystroke echo should never be interrupted by the results region.
- Repeat test. Search the same term twice in a row. The second search should not re-announce an identical message.
- Focus stability. Watch the focus ring during a search. It must not move. This is the check most likely to catch a regression after a refactor.
- Automated check. Assert the region's text in a component test with Testing Library — the technique is covered in testing ARIA live regions with Jest and Testing Library.
Common Accessibility Mistakes
- Adding the region and its text in the same render. Nothing is announced, because the region was not in the tree when the change happened.
- Using
aria-live="assertive". It interrupts the user mid-word. Reserve assertive for genuine emergencies; search results are not one. - Announcing on every keystroke. Eight messages for an eight-character query, each cutting off the last.
- Hiding the region with
display: none. It is removed from the accessibility tree and never speaks. - Putting the count only in a visible heading. Sighted users see it; nobody else is told anything, because a heading change is not a status message.
- Announcing sort order and page number. They change on their own and repeat constantly, turning the region into background noise.
Conclusion
One region, rendered always, updated only when a search settles, saying one sentence that names the count, the query and the filters — that is the whole feature. The discipline is in what you leave out: no assertive urgency, no per-keystroke updates, no repeated identical messages, and above all no focus movement. Get those four restraints right and a screen reader user experiences the same thing a sighted user does — a quiet interface that tells them what happened, once, when it is ready.
Frequently Asked Questions
Should the count also be visible on screen?
Yes, and that visible text can be the live region itself — a role="status" element does not have to be hidden. Everyone benefits from seeing "18 results for focus management", and having a single element serve both audiences guarantees they cannot disagree.
What is the difference between role="status" and aria-live="polite"?role="status" implies aria-live="polite" plus aria-atomic="true", meaning the whole message is re-read on each change rather than only the changed words. For a short sentence that is exactly what you want, and the role communicates intent better than the bare attribute.
How do I announce results loaded by infinite scroll? Ideally you do not use infinite scroll — but if you must, pair it with a "Load more" button so there is a user action to attach the announcement to, then say what arrived and the new total: "12 more results loaded, 30 total".
Does the announcement need to mention the sort order? No. Include only what changed as a result of the user's action, and only if it affects the count. Sort order changes the sequence, not the number of results, and repeating it in every announcement makes the important part harder to hear.
Can I announce the first result's title as well? Resist it. The user will read the results themselves in a moment, and the region should give them the shape of the response, not its content. The exception is a single-result case where you navigate automatically — and that behaviour needs its own announcement explaining that the page changed.
Related guides
- Accessible Search & Filtering in React — the parent guide covering the whole interaction.
- Dynamic Content & State Announcements — how live regions behave in React.
- Building a useAnnouncer Hook for Live Regions — one shared region for the whole app.
- Testing ARIA Live Regions with Jest and Testing Library — asserting that the message actually fires.