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.
Table of Contents
- 1. Why Classic Search Widgets Need the ARIA Combobox Pattern
- 2. Basic Structure: role="combobox", aria-expanded, aria-controls
- 3. aria-activedescendant Instead of Moving Focus
- 4. Implementing Arrow-Key Navigation Through Suggestions
- 5. Announcing the Result Count to Screen Readers
- 6. Highlighting the Search Term Without Screen Reader Noise
- 7. Distinguishing This from Datepicker and Classic Combobox Patterns
- 8. Making Magento Hyvä Live Search Accessible
- 9. Common Mistakes in Autocomplete Implementations
- 10. Summary
- 11. FAQ
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.
8. Making Magento Hyvä Live Search Accessible
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".