# Search and filter results that update without a word

Source: https://easeweb.dev/learn/search-results
Topics: Live updates > Search and filter results say how many were found (WCAG 4.1.3)
The fix: Write the number of results into a status region that is on the page before the first search, once the results are in and typing has paused. Either give the visible count line role="status" itself, or, when the count is rebuilt along with the results, keep a visually hidden status region in the page layout and write the same words there. Don't put aria-live on the results list, and don't move focus to the results.
Test with: screen reader, keyboard
References: https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/status_role https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-live

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A small kitchenware shop lists 6 products under a search field, with the line "Showing all 6 products" above them. The search runs as you type: a moment after you stop, the list redraws and the line changes to "3 products found for apron". A sighted user sees the list shrink and reads the number. A screen reader user types "apron", pauses, and hears nothing but the letters they typed. They don't know whether the search has run, whether it found three aprons or none, or whether they should press Enter. So they leave the field and walk through the list, item by item, to count what is there. When a search matches nothing, the line says so, and they don't hear that either.

## Why it fails

"3 products found for apron" is a status message: it reports the result of something the person did, and it does not take focus. Success Criterion 4.1.3 asks that such messages can be presented to assistive technology through role or properties, so that they are announced without moving focus. The W3C's first example for the criterion is close to this one: search results that appear below the search button, with a line near their top that says how many were returned. Here the line is a plain `p`. Its text changes on screen, but nothing tells assistive technology to read the change, so the result of the search is visible and silent.

## Who is affected

Live search is built for people watching the list. On a phone, the list is often below the on-screen keyboard, so even sighted users depend on the count line, and anyone who isn't looking at it at all gets nothing.

## What automation and AI miss

A scan loads the page and sees "Showing all 6 products" in the markup; it never types in the search field, so it never sees the line change, and nothing in the HTML says the line is a result. AI assistants asked to make search results accessible often put `aria-live` on the results list itself. That reads out every product name after each pause, or nothing at all when the framework replaces the list, because a region that arrives with its content is not announced. Others move focus to the results, which takes the caret out of the field in the middle of a word. Only a screen reader, used while searching, shows what is said.

## Before: a count line that changes on screen only

```html
<label for="q">Search products</label>
<input type="search" id="q">
<p id="result-count">Showing all 6 products</p>
<ul id="results">…</ul>
```

```js
function countText(n, words) {
  if (!words) return `Showing all ${n} products`;
  if (n === 0) return `No products found for ${words}. Try another word.`;
  return n === 1 ? `1 product found for ${words}` : `${n} products found for ${words}`;
}

const update = debounce(async () => {
  const words = q.value.trim();
  const products = await search(words);
  renderResults(products);
  count.textContent = countText(products.length, words);
}, 500);

q.addEventListener('input', update);
```

The words are right and they sit in the right place, but the `p` is not a live region, so the change is drawn and never announced.

## After: the visible count as a status region

```html
<label for="q">Search products</label>
<input type="search" id="q">
<p id="result-count" role="status">Showing all 6 products</p>
<ul id="results">…</ul>
```

```js
function countText(n, words) {
  if (!words) return `Showing all ${n} products`;
  if (n === 0) return `No products found for ${words}. Try another word.`;
  return n === 1 ? `1 product found for ${words}` : `${n} products found for ${words}`;
}

// Clear first when the words are the same, so the change is still announced.
function say(text) {
  if (count.textContent === text) count.textContent = '';
  setTimeout(() => (count.textContent = text), 100);
}

const update = debounce(async () => {
  const words = q.value.trim();
  const products = await search(words);
  renderResults(products);
  say(countText(products.length, words));
}, 500);

q.addEventListener('input', update);
```

The count line was already on the page, outside the list that is redrawn, so `role="status"` turns it into a polite live region the browser is watching from the start. After the pause, the screen reader finishes what it is saying, then reads "3 products found for apron". The caret stays in the field. What is heard is always the line that is shown, and the only new code is the attribute and the `say` helper. This works only while the count line itself survives each search: if the page rebuilds it along with the results, use the region below instead.

## After: a hidden status region, when the count is rebuilt with the results

```html
<label for="q">Search products</label>
<input type="search" id="q">
<!-- Once, in the page layout, outside the results component -->
<div id="search-status" role="status" class="visually-hidden"></div>
<div id="results-block">…</div>
```

```js
function countText(n, words) {
  if (!words) return `Showing all ${n} products`;
  if (n === 0) return `No products found for ${words}. Try another word.`;
  return n === 1 ? `1 product found for ${words}` : `${n} products found for ${words}`;
}

// Clear first when the words are the same, so the change is still announced.
function say(text) {
  if (status.textContent === text) status.textContent = '';
  setTimeout(() => (status.textContent = text), 100);
}

const update = debounce(async () => {
  const words = q.value.trim();
  const products = await search(words);
  renderResultsBlock(products); // rebuilds the visible count and the list
  say(countText(products.length, words));
}, 500);

q.addEventListener('input', update);
```

Many search widgets and component frameworks redraw the whole results block, count line included, on every search. A `role="status"` on that line would arrive with its words each time and say nothing. The empty region here lives in the page layout, outside anything that is rebuilt, so writing into it is a change the browser announces. The visible count stays as it was, for sighted users. Write the same words into both, so that what is heard matches what is shown.

## Which option to use

Both announce the number of results without moving focus. Use the visible count as the status region whenever that line survives each search: it is one attribute on an element that already exists, and what people hear can never drift from what they see. The hidden region is the right choice when the count is rebuilt with the results and you can't change that, for example in a third-party search widget or a results component that re-renders as a whole. It costs a second copy of the words to keep in step.

## Implementation decisions

**When to announce.** The moment depends on how the search is started:

- A search that runs as people type: once typing has paused and the results are in, not on every keystroke. A pause of about half a second lets a fast typist hear one count instead of five.
- Filters that apply as soon as they are ticked: after the results for that change are in. The screen reader reads the checkbox's own state first, then the count.
- A filter panel with an Apply button: after Apply. Keep focus on the button, or return it to the button that opened the panel if the panel closes.
- A search that loads a new page: the count is not a status message there. Put it in the page title and the main heading ("3 results for apron"), which are read as the page loads.

**Keep the region out of what is rebuilt.** In a component framework, render the region in the layout or in a part of the search component that stays mounted, never inside a keyed block, an `{#if}` or a list that is replaced with the results. A region that is created along with its message is the same failure in different code.

**Say the count, not the list.** Never put `aria-live` on the results list. People hear every product name after each pause, or nothing when the list is replaced, and either way the one thing they asked for, how many, is lost. The count line is enough: they can move into the list when they are ready.

**Zero results need words and a way out.** "No products found" alone leaves people stuck. Say what to try, such as another word or fewer filters, and if the search corrects spelling ("Showing results for apron"), put that in the same message.

**The same words twice.** Adding a space to the search, or ticking a filter that changes nothing, writes the same words again, and some screen readers say nothing when a region's text does not change. The `say` helper clears the region first and writes after a short delay, so the count is announced every time. On the visible line, that clears the text for a tenth of a second, which is barely seen.

**Slow searches.** If results take more than a moment, write "Searching…" into the same region, then the count. Don't write it for searches that return at once; it only adds noise. See [loading spinners that screen readers never announce](/learn/loading-states) for loading messages.

**Politeness.** `role="status"` waits for the screen reader to finish speaking, which is right for a count. Don't use `role="alert"` or `aria-live="assertive"`: it would cut into the echo of each key people type.

**Not fixes.** Moving focus to the results or to the count line does get it read, but it takes the caret out of the field, and people have to find their way back to refine the search. `aria-busy` on the list does not announce anything. Suggestions that drop down under the field while typing are a different pattern, a combobox, with their own rules.

## Verify the fix

1. Inspect the DOM before searching: the count line, or the separate region, is present with `role="status"`, is not inside the element that is rebuilt with the results, and is not hidden with `display: none` or `aria-hidden`.
2. With a screen reader on, type a word in the search field and stop: the number of results is announced once, after typing stops, and the caret stays in the field.
3. If the page has filters, tick one; then search for a word nothing matches: the new count is announced after the checkbox state, and the no-results message is announced with what to try.
4. Add a space after the search word, then type a long word quickly: the unchanged count is announced again, fast typing gives one announcement per pause, and the list itself is never read out.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the search component, the results template or the page layout changes.

## Limits

Live regions behave differently across screen readers and browsers, and some drop a polite message when people keep typing. This pattern covers one search field on one page. Saved-draft messages are covered in [status messages that are shown but never announced](/learn/status-messages); infinite scroll and Load more are a loading topic of their own. Test your real search, with its real filters, sorting and pagination, with the screen readers your users use.
