Keyboard Navigation for Search Results
A search interface that works perfectly with a mouse can be entirely unusable with a keyboard, and the failure is invisible to everyone who tests it by clicking. The specific problems are narrow and fixable: focus that jumps when results update, arrow keys that scroll the page instead of moving through results, and a screen reader that announces nothing at all while the visible list changes. This page fixes each. It sits under search-as-you-type interfaces in search frontend and UX patterns.
Prerequisites
- Control over the result markup — this cannot be retrofitted onto a component that renders results as unlabelled divs.
- A screen reader for testing: VoiceOver on macOS, NVDA on Windows, or Orca on Linux. Testing without one is guessing.
- Results rendered from a list you control, so
aria-activedescendantcan reference stable element ids.
Diagnosis: three failures, all invisible to mouse testing
Focus loss on update. In a search-as-you-type interface the result list re-renders on every response. If the implementation replaces the list’s DOM, whatever had focus is destroyed and focus falls back to the document body — so the next arrow key scrolls the page rather than moving through results. To a keyboard user the interface simply stops responding.
Arrow keys not intercepted. Down-arrow scrolls the page by default. Unless the input handles the key and prevents the default, a user who types a query and presses down watches the page scroll away from the results they were trying to reach.
Silent updates. A screen-reader user types, results change, and nothing is announced. The visual change conveys everything; the accessible change conveys nothing, so the user has no way to know whether anything happened.
keyboard-only session, typical broken implementation:
type "trail" -> results render (no announcement)
press ArrowDown -> page scrolls (focus is on body)
press Tab -> focus jumps to footer (results are not in the tab order)
press Enter -> nothing
None of that is discoverable by clicking through the interface, which is why these bugs survive review so reliably.
Solution Steps
1. Use the combobox pattern rather than moving focus
The key insight is that focus never leaves the input. The “active” result is indicated by aria-activedescendant, which points at an element id, and visually by a class. Moving real focus into the list breaks typing, because the user can no longer edit their query without tabbing back.
<input
id="q"
role="combobox"
aria-expanded="true"
aria-controls="results"
aria-activedescendant="result-3"
aria-autocomplete="list"
autocomplete="off">
<ul id="results" role="listbox" aria-label="Search results">
<li id="result-1" role="option" aria-selected="false">Trail Runner 3</li>
<li id="result-2" role="option" aria-selected="false">Trail Runner 4</li>
<li id="result-3" role="option" aria-selected="true" class="active">Trail Vest</li>
</ul>
2. Handle the keys the pattern defines
// keys.js — the full set a combobox is expected to support
input.addEventListener("keydown", (e) => {
const max = results.length - 1;
switch (e.key) {
case "ArrowDown": e.preventDefault(); setActive(Math.min(active + 1, max)); break;
case "ArrowUp": e.preventDefault(); setActive(Math.max(active - 1, 0)); break;
case "Home": if (isOpen) { e.preventDefault(); setActive(0); } break;
case "End": if (isOpen) { e.preventDefault(); setActive(max); } break;
case "Enter": if (active >= 0) { e.preventDefault(); open(results[active]); } break;
case "Escape": e.preventDefault(); close(); input.select(); break;
case "Tab": close(); break; // Tab leaves the widget; do not trap it
}
});
Escape closing the list and selecting the input text is a small detail that keyboard users notice immediately, because it is what every native combobox does.
3. Preserve the active item across result updates
When new results arrive, the previous active index usually points at a different document. Resetting to none is the correct default — silently keeping index 3 means the highlighted item changes identity underneath the user.
function renderResults(newResults) {
const previousId = active >= 0 ? results[active]?.id : null;
results = newResults;
const stillPresent = results.findIndex((r) => r.id === previousId);
setActive(stillPresent >= 0 ? stillPresent : -1); // follow the document, or reset
}
4. Announce updates through a live region
<div aria-live="polite" aria-atomic="true" class="visually-hidden" id="status"></div>
// Debounce the announcement so a fast typist is not read a new count per keystroke.
let announceTimer;
function announce(count) {
clearTimeout(announceTimer);
announceTimer = setTimeout(() => {
document.getElementById("status").textContent =
count === 0 ? "No results" : `${count} result${count === 1 ? "" : "s"} available`;
}, 600);
}
polite rather than assertive matters: assertive interrupts whatever the screen reader is currently saying, which during typing is the user’s own input.
5. Keep the active item scrolled into view
function setActive(i) {
active = i;
document.querySelectorAll("[role=option]").forEach((el, idx) => {
el.setAttribute("aria-selected", String(idx === i));
el.classList.toggle("active", idx === i);
});
input.setAttribute("aria-activedescendant", i >= 0 ? `result-${i + 1}` : "");
if (i >= 0) results_el.children[i].scrollIntoView({ block: "nearest" });
}
block: "nearest" scrolls only when the item is actually out of view, which avoids the jarring re-centring that block: "center" produces on every keypress.
Verification
Confirm the whole flow works without touching the mouse:
Tab to the input · type "trail" · ArrowDown ×3 · Enter
Expected: the third result opens, and the page never scrolled during navigation.
Confirm the accessible state is being updated:
// In the console, after pressing ArrowDown twice:
document.getElementById("q").getAttribute("aria-activedescendant");
// => "result-2"
document.querySelector('[aria-selected="true"]').textContent;
// => "Trail Runner 4"
Confirm a screen reader announces the count. This one genuinely requires the screen reader — there is no programmatic substitute for hearing whether the announcement fires and whether it interrupts.
VoiceOver: type "trail"
Expected: "34 results available" after the typing pause, not on every keystroke.
Beyond the dropdown: the full results page
The combobox pattern covers the autocomplete dropdown. A full results page has different requirements, because the results are page content rather than a popup, and applying combobox semantics there is a common mistake — a results page is not a listbox and announcing it as one is misleading.
On a results page, the results should be a plain list of links in the natural tab order, with three additions. A skip link that jumps from the search input past the facets directly to the results, because tabbing through forty facet checkboxes to reach the first result is the most common keyboard complaint about faceted search. A heading immediately before the results, so screen-reader users can jump to it with heading navigation. And a live region announcing the count when results update after a filter change, exactly as in the dropdown case.
<a href="#results" class="skip-link">Skip to results</a>
<form role="search"> … </form>
<aside aria-label="Filters"> … </aside>
<h2 id="results-heading">34 results for "trail runner"</h2>
<div aria-live="polite" class="visually-hidden">34 results</div>
<ol id="results" aria-labelledby="results-heading"> … </ol>
Putting the count in the heading as well as the live region means it is available both to someone navigating by headings and to someone who has just changed a filter — two different users with the same information need.
Common Pitfalls
Moving real focus into the result list
Using element.focus() on each result as the user arrows through is the intuitive implementation and it breaks the interface: the input loses focus, so typing further characters does nothing, and the user has to tab back to refine their query. The combobox pattern exists specifically to avoid this — focus stays put and only aria-activedescendant moves.
Assertive live regions during typing
aria-live="assertive" interrupts the screen reader mid-sentence, which during search-as-you-type means interrupting the announcement of the character the user just typed. It produces an experience that is worse than no announcement at all. Use polite, and debounce so the announcement fires once per pause rather than once per keystroke.
Result items that are not links
Rendering results as div elements with click handlers removes them from the accessibility tree as navigable targets and breaks middle-click, open-in-new-tab and copy-link — behaviours users rely on without thinking. Render an <a href> inside each option, so the result works as a link whether it is activated by Enter, a click, or a keyboard shortcut.
Operational notes
Write the keyboard contract down alongside the component. “Arrow keys move the active option, Enter opens it, Escape closes and restores the query, Tab leaves the widget” is four lines that prevent the slow divergence where each new surface implements slightly different key handling and users have to relearn the interface per page.
Add the keyboard flow to whatever automated test suite exists, because it regresses silently with any refactor of the result component. A test that types a query, sends three arrow keys and asserts aria-activedescendant costs a few lines and catches the whole class of regression.
Testing with a real screen reader once per significant change is worth the twenty minutes, and it is the only way to catch announcement problems — no automated tool reports that an announcement was technically present but interrupted the user, or that the count was read out fourteen times during one word. Automated accessibility checks catch missing attributes; they cannot catch an experience that is correct on paper and unusable in practice.
One further habit that costs nothing: use the interface with the mouse unplugged for five minutes after any change to the result component. It is not a substitute for screen-reader testing, and it catches the two most common regressions — lost focus and unhandled arrow keys — immediately.
Related
- Search-as-you-type interfaces — the parent guide, including the state machine these interactions operate over.
- Query autocomplete and suggestions — the dropdown this pattern most often applies to.
- Search frontend and UX patterns — the wider accessibility requirements this page implements one part of.