Making Search and Autocomplete Suggestions Accessible
AI generated
A11Y
WCAG
Accessibility · Search Field · ARIA Combobox
Making Search and Autocomplete Suggestions Accessible
Implementing the ARIA combobox pattern correctly, without focus chaos

A search field with live suggestions looks self-explanatory to sighted users, but without the ARIA combobox pattern it is often simply unusable for keyboard and screen reader users.

10 min read role=combobox aria-activedescendant Live Search

1. Why Classic Search Widgets Need the ARIA Combobox Pattern

A simple text field with a suggestion list displayed below it looks like a single connected element to sighted users. Technically, though, it is two separate DOM building blocks, an input element and a list, whose relationship is not recognizable to screen readers without extra ARIA attributes.

Without the combobox pattern, a screen reader user does not learn that new suggestions appear while typing, cannot meaningfully browse them, and does not know which suggestion is currently marked active. The result is a search field that works purely visually but stays unusable for a whole segment of users.

The W3C ARIA Authoring Practices Guide defines exactly this case with the combobox pattern and a fixed role structure. It pays off to follow this pattern exactly instead of inventing a custom variant, since screen readers tune their announcements precisely to this structure.

2. Basic Structure: role="combobox", aria-expanded, aria-controls

The input field itself gets role="combobox", even though it is technically a plain input element. In addition, aria-expanded indicates whether the suggestion list is currently visible, and aria-controls points to the list's id, so the relationship between the two elements is unambiguous for screen readers.

The suggestion list itself gets role="listbox", and each individual suggestion inside it gets role="option". This structure holds regardless of whether the list is rendered as a ul, a div, or a custom component, as long as the ARIA roles are set consistently.

A common mistake is setting aria-expanded permanently to true or leaving it out entirely, because the list is toggled via CSS anyway. Screen readers rely on the ARIA state, however, not on visual visibility, which is why the two must be kept in sync.


<label for="site-search">Search</label>
<input
  type="text"
  id="site-search"
  role="combobox"
  aria-expanded="false"
  aria-controls="site-search-listbox"
  aria-autocomplete="list"
  autocomplete="off"
>
<ul id="site-search-listbox" role="listbox" hidden>
  <li role="option" id="option-1">Men's Winter Jacket</li>
  <li role="option" id="option-2">Men's Cargo Pants</li>
</ul>

3. aria-activedescendant Instead of Moving Focus

When navigating the suggestion list with arrow keys, actual keyboard focus stays on the input element. Instead of moving focus onto each individual option, aria-activedescendant on the input element communicates which option currently counts as active.

This pattern has a decisive advantage over real focus movement: the user can keep typing at any time without having to bring focus back to the input field first. With real focus movement onto each option, every key press would unexpectedly bounce focus back into the field.

The active option also needs to be visually highlighted, usually through a dedicated CSS class applied in parallel with the aria-activedescendant value via JavaScript. Only the combination of visual and programmatic marking makes the selection equally understandable for sighted and blind users.


let activeIndex = -1;
const options = Array.from(listbox.querySelectorAll('[role="option"]'));

function setActiveOption(index) {
  options.forEach((el) => el.classList.remove('bg-slate-100'));
  activeIndex = index;
  if (index >= 0) {
    options[index].classList.add('bg-slate-100');
    input.setAttribute('aria-activedescendant', options[index].id);
  } else {
    input.removeAttribute('aria-activedescendant');
  }
}

4. Implementing Arrow-Key Navigation Through Suggestions

Keyboard control follows a fixed, documented pattern: arrow-down moves the active selection to the next option, arrow-up to the previous one, Enter commits the active option into the input field, and Escape closes the list without committing a selection.

At the bottom and top of the list, navigation should either stop or wrap to the opposite end, consistent with the behavior screen reader users know from native select fields. A selection that suddenly disappears at the end of the list, by contrast, feels like a bug.

The Tab key should never be repurposed for list navigation; it should only ever leave the entire search field, just like with any other form element. Repurposing Tab for list navigation breaks the expectations of every keyboard user, not just those relying on a screen reader.


input.addEventListener('keydown', (event) => {
  switch (event.key) {
    case 'ArrowDown':
      event.preventDefault();
      setActiveOption(Math.min(activeIndex + 1, options.length - 1));
      break;
    case 'ArrowUp':
      event.preventDefault();
      setActiveOption(Math.max(activeIndex - 1, 0));
      break;
    case 'Enter':
      if (activeIndex >= 0) {
        input.value = options[activeIndex].textContent;
        closeListbox();
      }
      break;
    case 'Escape':
      closeListbox();
      break;
  }
});

5. Announcing the Result Count to Screen Readers

Besides navigating individual suggestions, the total result count is also important information that sighted users grasp visually right away, but screen reader users need to have actively communicated to them. Without it, it stays unclear whether any results exist at all or whether loading is still in progress.

A separate, unobtrusive live region, independent from the suggestion list itself, works better for this than an announcement directly inside the listbox. A message like "5 results found" or "No results found" is entirely sufficient as an aria-live="polite" announcement.

It matters to trigger this announcement only after the search completes, not on every single keystroke while typing. A server-side debounce of 200 to 300 milliseconds prevents both unnecessary server requests and a flood of intermediate announcements that would acoustically drown out the actual search process.


// Live region in markup: <div aria-live="polite" class="sr-only" data-search-status></div>
function updateResultCount(count) {
  const status = document.querySelector('[data-search-status]');
  status.textContent = count === 0
    ? 'No results found.'
    : `${count} results found.`;
}

6. Highlighting the Search Term Without Screen Reader Noise

Visually highlighting the searched term inside suggestions, usually via a strong or mark element, is a helpful orientation aid for sighted users. Screen readers read mark elements by default without special emphasis, which in most cases is also the right way to handle it.

Problems arise when there is an additional attempt to acoustically reproduce the highlighting, for example through inserted symbols or repeated announcements of the highlighted text portion. That leads to unnecessarily long, hard-to-follow announcements and should be avoided.

A sensible compromise is to leave the highlighting purely visual and instead optimize the total result count and a clear structure of the individual options for acoustic perception, rather than trying to reproduce every visual detail one-to-one acoustically.

7. Distinguishing This from Datepicker and Classic Combobox Patterns

The search field pattern with live suggestions differs from classic selection comboboxes and datepickers mainly in that free text can be entered that does not necessarily match any suggestion. With a classic combobox or a datepicker, usually exactly one valid value from a fixed set needs to be chosen.

This distinction directly affects aria-autocomplete: search fields with free text entry usually use aria-autocomplete="list", while stricter selection fields with automatic completion tend to use aria-autocomplete="both", which additionally allows an inline completion suggestion right inside the input field.

A separate article in this series covers accessible datepickers and classic comboboxes in detail, including their specific keyboard control for date selection. Mixing up the two patterns and implementing them identically produces confusing expectation mismatches for users, for example when Enter in a search unexpectedly submits an entire form instead of just committing the selection.

The Hyvä live search component is typically built on Alpine.js and a debounced fetch call against the Magento search API. For an accessible implementation, the ARIA attributes of the combobox pattern need to be maintained alongside the existing x-show/x-model state, not bolted on afterward.

A practical approach is deriving the aria-expanded and aria-activedescendant values directly from the same Alpine.js data points that also drive the visible display, instead of building a parallel, potentially inconsistent second state management layer.

The server-side result count from the Magento search API can be fed directly into the separate live region for the announcement, without extra frontend logic, since the API already ships that number for displaying the results anyway.


<div x-data="liveSearch()" class="relative">
  <input
    type="text"
    role="combobox"
    :aria-expanded="open ? 'true' : 'false'"
    aria-controls="live-search-listbox"
    :aria-activedescendant="activeId"
    x-model="query"
    @input.debounce.250ms="search()"
    @keydown.arrow-down.prevent="moveActive(1)"
    @keydown.arrow-up.prevent="moveActive(-1)"
    @keydown.enter="selectActive()"
    @keydown.escape="close()"
  >
  <ul id="live-search-listbox" role="listbox" x-show="open">
    <template x-for="(result, index) in results" :key="result.id">
      <li role="option" :id="'option-' + result.id" x-text="result.name"></li>
    </template>
  </ul>
  <div aria-live="polite" class="sr-only" x-text="statusMessage"></div>
</div>

9. Common Mistakes in Autocomplete Implementations

The most widespread mistake is probably building the suggestion list entirely without ARIA roles, as a plain div with click handlers. Visually that works flawlessly, but for keyboard and screen reader users the list stays effectively invisible and unusable.

A second common mistake is moving real focus onto the first option when the list opens, instead of using aria-activedescendant. That immediately interrupts text entry, since focus leaves the input element as soon as a suggestion appears.

A third mistake is a missing or incorrectly synchronized aria-expanded value, which causes screen readers to announce a list even though it has long since closed visually, or conversely to stay silent about a list that is actually open. Both cases feel strongly disorienting to users and undermine trust in the screen reader's announcements.

ARIA Attribute Location Purpose Common Mistake
role="combobox" input element Marks the field as an expandable input Missing entirely or set on the wrong element
aria-expanded input element Indicates visibility of the suggestion list Not kept in sync with actual CSS visibility
aria-activedescendant input element Marks the currently active option Missing, focus is moved instead
role="listbox" suggestion container Marks the list as a selection set Container stays a roleless plain div
role="option" individual suggestion Marks a selectable entry Missing unique id for aria-activedescendant

Mironsoft

WCAG audits, accessible Magento shops, and training

Not sure whether the shop is actually accessible?

We audit existing Magento shops against WCAG 2.2, fix concrete barriers in the Hyvä frontend, and train teams so accessibility stays anchored in the development process for good.

WCAG Audit

Systematically review the shop against WCAG 2.2 AA, with a prioritized issue list.

Fixing Barriers

Concrete implementation: keyboard operability, screen reader support, contrast, forms.

Team Training

Raise developer and editor awareness for accessible implementation day to day.

10. Summary

Search Autocomplete

Basic Structure

Consistently set role="combobox" on the input, role="listbox" and role="option" for the suggestion list.

Active Selection

Use aria-activedescendant instead of real focus movement so text entry is not interrupted.

Result Count

Announce the result count through a separate, throttled aria-live region, not on every keystroke.

Distinction

Separate free-text search fields with aria-autocomplete="list" from stricter selection comboboxes using "both".

11. FAQ: Search Autocomplete

1Why isn't a plain input field with a CSS dropdown enough?
Because the relationship between input field and suggestion list is not recognizable to screen readers without ARIA attributes. Without role="combobox" and related attributes, the search field stays effectively unusable for keyboard and screen reader users.
2What's the difference between aria-activedescendant and real focus movement?
aria-activedescendant marks an option as active without changing actual keyboard focus. That lets the user keep typing, while real focus movement would pull focus out of the input field on every navigation step.
3How do I show screen reader users the result count?
Through a separate aria-live="polite" region with a short message like 5 results found, updated only after the search completes, not on every keystroke.
4Should I acoustically reproduce the highlighting of the search term too?
No, that leads to unnecessarily long and hard-to-follow announcements. The highlighting stays purely visual, while the total result count matters more for acoustic perception.
5What does aria-autocomplete="list" mean compared to "both"?
list only shows a list of suggestions, both additionally allows automatic inline completion right inside the input field, which tends to make sense for stricter selection fields.
6How does the search field pattern differ from a datepicker?
Search fields allow free text that does not have to match any suggestion, while datepickers and classic comboboxes usually expect exactly one valid value from a fixed set.
7Why shouldn't Tab be used to navigate the suggestion list?
Because Tab consistently leaves the field across all form elements. Repurposing Tab contradicts the expectations of every keyboard user.
8How often should the search trigger while typing?
With a debounce of roughly 200 to 300 milliseconds, to avoid unnecessary server requests and a flood of intermediate announcements.
9Does the suggestion list have to be a ul?
No, what matters are the ARIA roles listbox and option, not the specific HTML element. A div with the right roles works correctly too.
10What's the most common mistake in homegrown autocomplete fields?
Building the suggestion list entirely without ARIA roles as a plain div with click handlers, which works visually but stays unusable for keyboard and screen reader users.