react nextjs accessibility patterns

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 Relationships
  • 2.1.1 Keyboard
  • 3.2.2 On Input
  • 3.3.2 Labels or Instructions
  • 4.1.2 Name, Role, Value
  • 4.1.3 Status Messages
What a search interaction has to announce, and when A timeline in four steps. The user types a query, and nothing is announced yet because typing must not interrupt. Suggestions appear and the number of options is announced through the combobox, while focus stays in the input. The user submits, and a polite status message announces that results are loading. Results arrive and a single polite message states how many were found and which filters are active. A footnote notes that focus never moves during any of these announcements. 1 · User types Say nothing. Typing echoes already; extra speech collides. 2 · Suggestions The combobox reports expanded state and the active option. 3 · Submitted One polite status: "Searching…" — only if the wait is real. 4 · Results "18 results for focus, filtered by React" — count plus context. Focus stays in the input for all four steps — announcements are made by live regions, never by moving the cursor.

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 by htmlFor/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 ("try author:kim"), put it in visible helper text referenced with aria-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-controls and aria-autocomplete="list" on the input itself — not on a wrapper <div>.
  • Track the highlighted option with aria-activedescendant pointing at the option's id; do not move DOM focus into the list.
  • Implement ArrowDown/ArrowUp to move the highlight, Enter to accept, Escape to close and restore the typed value, and Home/End for 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.

Structure of an accessible filter panel A filter panel on the left is built from two fieldsets, each with a legend: Framework, containing three checkboxes, and Level, containing two radio buttons. An applied-filters row sits above the results on the right, listing each active filter as a removable button whose name includes the word remove. A status line above the results announces the count. Arrows show that changing any control updates both the applied-filters row and the status line, while focus stays on the control the user just changed. <form> Filters <legend> Framework ☐ React ☐ Next.js ☐ Vue <legend> Level ○ Introductory ○ Advanced Each fieldset names its group. role="status" · polite 18 results for "focus", filtered by React Applied filters Remove React filter Remove Advanced filter Results list Replaced in place. Focus never moves here on its own — the user keeps the control they were operating. Each result is a heading plus one link, so the headings rotor doubles as a results index.

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.
What each search state announces Four states of a results region with the message each one sends to the polite live region. Idle says nothing. Loading says nothing for the first five hundred milliseconds and then says Searching. Results announces the count together with the query. Error announces that the search failed and points at a retry button. Arrows show that loading can return to results or to error, and that the previous results stay visible throughout. Idle region empty Loading silent for 500 ms, then "Searching…" Results "18 results for focus, filtered by React" Error "Search failed. Try again." + retry button Previous results stay on screen for every transition — emptying the list first is announced as "0 results". One region, one message at a time: never stack "Searching…" and the count in the same update.
{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" with aria-modal="true", named by its own heading or aria-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: ArrowDown should 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, and Escape should 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-value and label catch 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 in role="search" so it is reachable from the landmark list.
  • Suggestions mean the full combobox contract: state on the input, aria-activedescendant for 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.