Accessible Search & Filtering in React
Search is the densest accessibility problem in a typical product. In one interaction you have a text input that must be labelled, a suggestion list with its own keyboard contract, a results region that changes without a page load, a set of filters that change it again, and loading and empty states in between. Each part is individually well-understood; the failure is almost always that they were built separately and nobody checked what the whole thing sounds like.
This guide walks the interaction end to end, in React. It sits under React & Next.js Accessibility Patterns and leans on two neighbours: Dynamic Content & State Announcements for the live-region machinery, and Keyboard Navigation Patterns for Modals for the focus rules that overlay panels share.
WCAG Success Criteria Addressed:
1.3.1 Info and Relationships2.1.1 Keyboard3.2.2 On Input3.3.2 Labels or Instructions4.1.2 Name, Role, Value4.1.3 Status Messages
Label the Input, Name the Region
A magnifying-glass icon is not a label. Placeholder text is not a label either — it disappears on first keystroke, is often below contrast requirements, and is announced inconsistently. 3.3.2 Labels or Instructions wants a persistent, programmatic label, and search inputs are the control most likely to ship without one.
Implementation Guidelines:
- Use a real
<label>tied to the input byhtmlFor/id. Hide it visually if the design demands, but keep it in the DOM. - Wrap the whole control in
<form role="search">(or<search>where support allows) so screen reader users can jump straight to it from the landmark list. - Add
type="search"for the native clear affordance and mobile keyboard, and give any custom clear button its own name. - Do not put instructions only in a
placeholder. If the syntax matters ("tryauthor:kim"), put it in visible helper text referenced witharia-describedby.
export function SearchForm({ onSubmit }: { onSubmit: (q: string) => void }) {
return (
<form
role="search"
onSubmit={(event) => {
event.preventDefault();
onSubmit(new FormData(event.currentTarget).get('q') as string);
}}
>
<label htmlFor="site-search">Search guides</label>
<input
id="site-search"
name="q"
type="search"
aria-describedby="search-hint"
/>
<p id="search-hint">Search by topic, component name, or WCAG criterion.</p>
<button type="submit">Search</button>
</form>
);
}
Keeping a visible submit button matters more than it appears. Search-as-you-type is convenient for mouse users, but a keyboard user needs a deliberate way to say "run it now", and a screen reader user needs a predictable moment at which results change. 3.2.2 On Input is about exactly that: changing context without an explicit action is disorienting.
Suggestions Need the Full Combobox Contract
The moment a list of suggestions appears under the input, you have signed up for the combobox pattern: state on the input, roles on the list, and a keyboard model where arrow keys move a highlight while real focus stays put.
Implementation Guidelines:
- Put
role="combobox",aria-expanded,aria-controlsandaria-autocomplete="list"on the input itself — not on a wrapper<div>. - Track the highlighted option with
aria-activedescendantpointing at the option'sid; do not move DOM focus into the list. - Implement
ArrowDown/ArrowUpto move the highlight,Enterto accept,Escapeto close and restore the typed value, andHome/Endfor the ends of the list. - Keep the input's value under the user's control while navigating suggestions, or provide a way back to what they typed. Silently rewriting the field on every arrow press is disorienting.
'use client';
export function SearchCombobox({ suggestions }: { suggestions: Suggestion[] }) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const activeId = activeIndex >= 0 ? `sug-${suggestions[activeIndex].id}` : undefined;
return (
<>
<input
role="combobox"
aria-expanded={open}
aria-controls="search-suggestions"
aria-autocomplete="list"
// A11y rationale: 4.1.2 Name, Role, Value — the highlighted option is
// reported through activedescendant while DOM focus stays in the input.
aria-activedescendant={activeId}
onKeyDown={handleKeyDown}
/>
<ul id="search-suggestions" role="listbox" hidden={!open}>
{suggestions.map((suggestion, index) => (
<li
key={suggestion.id}
id={`sug-${suggestion.id}`}
role="option"
aria-selected={index === activeIndex}
>
{suggestion.label}
</li>
))}
</ul>
</>
);
}
Because the highlight is virtual, CSS has to do the job the browser's focus ring normally does: style [aria-selected="true"] clearly enough that a sighted keyboard user can see where they are. The same virtual-focus model, with its trade-offs against moving real focus, is unpacked in building accessible dropdowns without external UI kits.
Announce Results Once, and Say Something Useful
When results replace themselves without a page load, sighted users see the change instantly and screen reader users get nothing — unless a live region says so. The trap is over-correcting: a region that fires on every keystroke turns a search box into a stream of interruptions.
Implementation Guidelines:
- Use one
aria-live="polite"status region for result counts, and update it only when a search actually completes. - Debounce search-as-you-type by 300–500 ms before updating the message, so intermediate counts are never spoken.
- Say the count and the context: "18 results for focus management, filtered by React" beats "18 results".
- Render the region unconditionally and change only its text. A live region added to the DOM at the same moment as its content is frequently missed.
// Rendered on every state, empty until there is something worth saying.
<p role="status" aria-live="polite" className="visually-hidden">
{status === 'loading' ? 'Searching…' : resultMessage}
</p>
Announcing a count is also the cheapest way to make an empty result state accessible. "No results for focus mangement" tells a screen reader user both that the search ran and that the query may contain a typo — information sighted users read straight off the screen. The mechanics of building these regions once and reusing them are covered in building a useAnnouncer hook for live regions.
Filters Are a Form, Not a Toolbar
Filter panels drift towards custom widgets — chips, pills, segmented controls — when the underlying interaction is almost always a set of checkboxes or radios. Native form controls come with a keyboard model, a state announcement and a grouping mechanism for free.
Implementation Guidelines:
- Group related controls in a
<fieldset>with a<legend>. The legend is announced with each control, so "Framework, React, checkbox" tells the user what the checkbox belongs to. - Prefer checkboxes for multi-select and radios for single-select over custom chip buttons. If chips are a design requirement, make them real
<input type="checkbox">elements styled as chips. - Give removable filter chips names that include the verb: "Remove React filter" rather than "React ×".
- Apply filters on change, but announce the new count once, through the same status region the search uses — not once per filter.
<fieldset>
<legend>Framework</legend>
{frameworks.map((framework) => (
<label key={framework.id}>
<input
type="checkbox"
name="framework"
value={framework.id}
checked={selected.includes(framework.id)}
onChange={() => toggle(framework.id)}
/>
{framework.label}
</label>
))}
</fieldset>
Loading, Empty and Error States
The states between "typed" and "results" are where search interfaces lose their manners. A spinner with no text says nothing; a results list that empties and refills without a word leaves the user wondering whether the filter did anything at all.
Implementation Guidelines:
- Only announce loading when the wait is perceptible — roughly 500 ms or more. Announcing a 60 ms fetch produces "Searching… 18 results" back to back.
- Give skeleton placeholders
aria-hidden="true"and let the status region carry the meaning; a screen reader has no use for eight grey rectangles. - Keep the old results visible while new ones load rather than emptying the region. Empty-then-fill reads as "0 results" to anyone watching the count.
- Announce errors politely with a retry control that is reachable by keyboard, and never move focus to the error without warning.
{isPending && <SkeletonList aria-hidden="true" />}
<p role="status" aria-live="polite" className="visually-hidden">
{isPending && slow ? 'Searching…' : null}
{error ? 'Search failed. Try again.' : null}
{!isPending && !error ? `${results.length} results for ${query}` : null}
</p>
Structuring the Results Themselves
The results region is content, and it benefits from the same structure as any other content: a heading per result, one link per destination, and a container that says how many items it holds.
Implementation Guidelines:
- Render results as a
<ul>of<li>items. Screen readers announce "list, 18 items", which corroborates the count you just announced and gives the user a sense of scale before they start reading. - Give each result a heading (
<h2>or<h3>depending on the page outline) containing the single link to that result. The headings rotor then works as a results index. - Avoid duplicate links per result — a thumbnail link plus a title link plus a "Read more" link means three tab stops and three identical entries in the links list for one destination.
- Highlight matched terms with
<mark>, which conveys the emphasis semantically, rather than a<span>with a background colour.
<ul className="results">
{results.map((result) => (
<li key={result.id}>
<h3>
<a href={result.href}>{result.title}</a>
</h3>
<p>{result.summary}</p>
</li>
))}
</ul>
Pagination deserves the same care as the results. Number links need a <nav aria-label="Search results pages"> wrapper, the current page needs aria-current="page", and "Next"/"Previous" need names that survive being read out of context — "Next page of results" rather than a bare chevron. The focus behaviour after a page change is the interesting part, and it is covered in accessible pagination for React data tables.
Infinite scroll is the harder case. Appending results as the user scrolls gives keyboard users no way to reach the footer and screen reader users no signal that anything arrived. If the product requires it, pair it with a real "Load more" button — the button gives everyone an explicit trigger, and the polite status region can then announce "12 more results loaded, 30 total" at a moment the user chose.
Keeping State in the URL
Search state belongs in the URL: it makes results shareable, survives a refresh, and — the accessibility payoff — makes the browser's Back button do what users expect after they narrow a search five times.
Implementation Guidelines:
- Serialise the query and active filters into search params, replacing rather than pushing history entries while the user is still typing.
- On restore, re-render the controls in their saved state so the visible UI and the results agree.
- After a Back navigation, announce the restored result count through the status region, since no page load occurred to do it for you.
- Never move focus on restore. Returning focus to the search input sounds helpful and reliably strands anyone who was reading the results.
The route-change side of this — announcing that a new view has loaded at all — is covered in announcing client-side route changes in React.
Search Inside a Command Palette
A command palette — the Cmd+K overlay that has become standard in developer tools — is a search box inside a dialog, and it inherits both contracts at once. Getting one right is mostly a matter of not letting the two fight.
Implementation Guidelines:
- The overlay is a
role="dialog"witharia-modal="true", named by its own heading oraria-label, with focus moved to the input on open and returned to the trigger on close. - The input inside it is still a combobox:
aria-expanded,aria-controls,aria-activedescendant— the dialog wrapper does not replace any of that. - Trap focus inside the dialog, but do not trap the arrow keys:
ArrowDownshould move the option highlight, not the browser's caret. - Announce the result count through a status region inside the dialog, so it is not muted while the dialog is modal.
- Provide a visible way to open the palette. A keyboard shortcut with no button is invisible to anyone who has not read the docs, and unreachable on touch.
<div role="dialog" aria-modal="true" aria-label="Command palette">
<label htmlFor="palette-input" className="visually-hidden">Search commands</label>
<input id="palette-input" role="combobox" aria-expanded aria-controls="palette-list" />
<ul id="palette-list" role="listbox">{/* options */}</ul>
<p role="status" aria-live="polite" className="visually-hidden">{countMessage}</p>
</div>
The focus rules here are identical to any other overlay, including the restoration contract when Escape closes it — see Keyboard Navigation Patterns for Modals for the full model, and fixing focus trap issues in React portals for the portal-specific traps.
How to Verify
- Keyboard-only run. Tab to the input, type, arrow through suggestions, press
Enter, then Tab into the filters and toggle two. You should never lose the focus ring, andEscapeshould always close the suggestion list without clearing the field. - Screen reader pass. With NVDA or VoiceOver, confirm exactly one announcement per completed search, that the count includes the query, and that arrowing through suggestions speaks each option once.
- Debounce check. Type a ten-character query and count the announcements. More than one means the live region is firing on keystrokes rather than on results.
- Automated scan. Run axe over the search page with the suggestion list open —
aria-required-children,aria-valid-attr-valueandlabelcatch most combobox wiring mistakes. See Automated Accessibility Testing with axe-core. - Back-button check. Search, filter twice, then press Back twice. The controls, the results and the announced count must agree at every step.
Key Takeaways
- A search UI has four announceable moments; only two of them should ever speak.
- Label the input with a real
<label>and wrap it inrole="search"so it is reachable from the landmark list. - Suggestions mean the full combobox contract: state on the input,
aria-activedescendantfor the highlight, real focus staying put. - Announce results once, politely, with the count and the query — and debounce so intermediate counts stay silent.
- Build filters from
<fieldset>-grouped native controls; style them as chips if you must, but keep the semantics. - Keep old results on screen while loading, hide skeletons from assistive technology, and keep search state in the URL.
Frequently Asked Questions
Should search results move focus when they arrive? No. The user asked for results, not for their cursor to be relocated — moving focus interrupts whatever they were doing and, on a slow connection, can yank focus mid-keystroke. Announce the count through a polite live region and leave focus where it is. The exception is an explicit "skip to results" link, which moves focus because the user activated it.
Is role="search" still needed if I use the <search> element?
Where <search> is supported it maps to the same landmark and the role is redundant. Browser and screen reader support is now good but not universal, so a <form role="search"> remains the safest choice for a while yet; adding both is harmless.
How do I stop a search-as-you-type field from spamming announcements? Debounce the announcement, not just the request. Update the live region only when a completed response renders, and only after 300–500 ms of quiet. If the same count comes back twice in a row, skip the update entirely — repeating "18 results" adds noise without information.
Do filter chips need aria-pressed?
Only if they really are toggle buttons. If they are checkboxes styled as chips — the recommended approach — the checked state is already announced and adding aria-pressed produces a control with two conflicting states. Pick one model and let it speak for itself.
Can I use aria-live="assertive" for the result count?
No — assertive interrupts whatever the user is currently hearing, including their own typing echo, which is precisely the experience polite regions exist to avoid. Reserve assertive for genuine emergencies such as a session about to expire. A search that finished is important to the user but not urgent enough to cut them off mid-sentence.
What should an empty state say?
The query, the fact that nothing matched, and the nearest useful next step: "No results for focus mangement. Check the spelling or clear the Framework filter." That single sentence does the job of a headline, a hint and a recovery path for every user, not just screen reader users.
Related guides
- React & Next.js Accessibility Patterns — the parent guide this pattern belongs to.
- Dynamic Content & State Announcements — the live-region rules the result count depends on.
- Accessible Data Tables & Grids in React — when results are tabular rather than a list.
- Building an Accessible Combobox in React — the suggestion list, implemented in full.
- Announcing Search Result Counts to Screen Readers — debouncing and message wording.
- Accessible Filter Chips & Facet Panels — grouping, removal and applied-filter summaries.
- Building Accessible Dropdowns Without External UI Kits — the virtual-focus model in depth.