// pattern
Accessible Combobox / Autocomplete Pattern
A text input with autocomplete filtering that opens a role="listbox" of suggestions. Uses role="combobox", aria-activedescendant for virtual focus, and a live region to announce result counts. No native HTML element supports this interaction — ARIA is required.
// 01 · live demo
Live Demo
Type to filter countries. Use Arrow Down/Arrow Up to navigate options. Press Enter to select, Escape to close.
- Australia
- Brazil
- Canada
- France
- Germany
- India
- Japan
- Mexico
- South Korea
- Spain
- United Kingdom
- United States
Inaccessible version (no ARIA, mouse-only):
// 02 · the code
The Code
<!-- Combobox container -->
<div class="combobox">
<label for="country-input">Choose a country</label>
<input
type="text"
id="country-input"
role="combobox"
aria-expanded="false"
aria-autocomplete="list"
aria-controls="country-listbox"
aria-activedescendant=""
autocomplete="off"
>
<ul role="listbox" id="country-listbox" hidden>
<li role="option" id="opt-1" aria-selected="false">
Australia
</li>
<li role="option" id="opt-2" aria-selected="false">
Brazil
</li>
<li role="option" id="opt-3" aria-selected="false">
Canada
</li>
<!-- More options... -->
</ul>
<!-- Live region for result count -->
<div class="sr-only" aria-live="polite"
id="combo-status"></div>
</div>
.combobox {
position: relative;
max-width: 20rem;
}
.combobox label {
display: block;
font-weight: 600;
margin-bottom: 0.25rem;
}
.combobox input[role="combobox"] {
width: 100%;
padding: 0.5rem 1rem;
font-size: 0.875rem;
border: 2px solid #d1d5db;
border-radius: 0.25rem;
background: #fff;
/* SC 2.5.8: minimum 24x24 target */
min-height: 2.75rem;
box-sizing: border-box;
}
/* SC 2.4.13: focus indicator with 3:1 contrast */
.combobox input[role="combobox"]:focus-visible {
outline: 2px solid #5b2a86;
outline-offset: 2px;
border-color: #5b2a86;
}
.combobox [role="listbox"] {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin: 0;
padding: 0.25rem 0;
list-style: none;
background: #fff;
border: 2px solid #d1d5db;
border-radius: 0.25rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-height: 14rem;
overflow-y: auto;
z-index: 10;
}
.combobox [role="listbox"][hidden] {
display: none;
}
.combobox [role="option"] {
padding: 0.5rem 1rem;
cursor: pointer;
/* SC 2.5.8: minimum 24x24 target */
min-height: 2.75rem;
display: flex;
align-items: center;
}
.combobox [role="option"]:hover {
background: #f3f4f6;
}
/* Active option via aria-selected */
.combobox [role="option"][aria-selected="true"] {
background: #5b2a86;
color: #fff;
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
.combobox input,
.combobox [role="listbox"] {
transition: none;
}
}
/* Screen reader only — live region */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
function initCombobox(container) {
const input = container.querySelector('[role="combobox"]');
const listbox = container.querySelector('[role="listbox"]');
const status = container.querySelector('[aria-live]');
const allOptions = Array.from(
listbox.querySelectorAll('[role="option"]')
);
let activeIndex = -1;
function getVisibleOptions() {
return allOptions.filter(
opt => !opt.hidden
);
}
function openListbox() {
listbox.hidden = false;
input.setAttribute('aria-expanded', 'true');
}
function closeListbox() {
listbox.hidden = true;
input.setAttribute('aria-expanded', 'false');
input.setAttribute('aria-activedescendant', '');
clearSelection();
activeIndex = -1;
}
function clearSelection() {
allOptions.forEach(opt => {
opt.setAttribute('aria-selected', 'false');
});
}
function setActiveOption(index) {
const visible = getVisibleOptions();
if (visible.length === 0) return;
clearSelection();
activeIndex = index;
const option = visible[activeIndex];
option.setAttribute('aria-selected', 'true');
input.setAttribute(
'aria-activedescendant', option.id
);
// Scroll option into view if needed
option.scrollIntoView({ block: 'nearest' });
}
function selectOption(option) {
input.value = option.textContent.trim();
closeListbox();
}
function filterOptions() {
const query = input.value.toLowerCase().trim();
let matchCount = 0;
allOptions.forEach(opt => {
const text = opt.textContent.toLowerCase();
const matches = text.includes(query);
opt.hidden = !matches;
if (matches) matchCount++;
});
activeIndex = -1;
clearSelection();
input.setAttribute('aria-activedescendant', '');
if (query.length > 0 && matchCount > 0) {
openListbox();
// Announce count via live region
status.textContent =
matchCount + (matchCount === 1
? ' result available'
: ' results available');
} else if (query.length > 0) {
openListbox();
status.textContent = 'No results available';
} else {
closeListbox();
status.textContent = '';
}
}
// Filter on input
input.addEventListener('input', filterOptions);
// Keyboard navigation
input.addEventListener('keydown', (e) => {
const visible = getVisibleOptions();
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (listbox.hidden) {
filterOptions();
openListbox();
}
if (visible.length > 0) {
const next = activeIndex < visible.length - 1
? activeIndex + 1 : 0;
setActiveOption(next);
}
break;
case 'ArrowUp':
e.preventDefault();
if (visible.length > 0) {
const prev = activeIndex > 0
? activeIndex - 1
: visible.length - 1;
setActiveOption(prev);
}
break;
case 'Home':
if (listbox.hidden) return;
e.preventDefault();
if (visible.length > 0) {
setActiveOption(0);
}
break;
case 'End':
if (listbox.hidden) return;
e.preventDefault();
if (visible.length > 0) {
setActiveOption(visible.length - 1);
}
break;
case 'Enter':
e.preventDefault();
if (activeIndex >= 0) {
selectOption(visible[activeIndex]);
}
break;
case 'Escape':
if (!listbox.hidden) {
e.preventDefault();
closeListbox();
}
break;
case 'Tab':
if (activeIndex >= 0 && !listbox.hidden) {
selectOption(visible[activeIndex]);
} else {
closeListbox();
}
// Don't prevent default — let Tab
// move focus to the next field
break;
}
});
// Click to select an option
listbox.addEventListener('click', (e) => {
const option = e.target.closest('[role="option"]');
if (option && !option.hidden) {
selectOption(option);
input.focus();
}
});
// Close on outside click
document.addEventListener('click', (e) => {
if (!container.contains(e.target)) {
closeListbox();
}
});
}
// Initialize all comboboxes on the page
document.querySelectorAll('.combobox')
.forEach(initCombobox);
autocomplete="off" on the input?
Browsers show their own autocomplete suggestions over the input, which conflicts with the custom listbox. Setting autocomplete="off" prevents the browser's native suggestions from overlapping your ARIA-powered listbox, avoiding confusion for both sighted and screen reader users.
// 03 · why these decisions
Why These Decisions
Why role="combobox"
There is no native HTML element that combines a text input with a filterable suggestions list. The <select> element doesn't support typing to filter, and <datalist> has severe cross-browser inconsistencies and no way to control styling or behavior. The combobox role tells screen readers that this input has an associated popup list of suggestions, enabling them to announce the correct interaction model.
Why aria-activedescendant instead of moving real focus
When a user arrows through options, real DOM focus stays in the input. Only the aria-activedescendant attribute changes to point to the active option's ID. This is essential because moving real focus to a list item would move the cursor out of the input — the user would lose their typing position and couldn't continue refining their search. Virtual focus via aria-activedescendant lets screen readers announce the active option while the user keeps typing.
Why aria-autocomplete="list"
This attribute tells screen readers that a list of suggestions will appear based on what the user types. Without it, screen readers announce a generic combobox with no hint that typing will produce filtered results. The "list" value specifically means the suggestions come from a predefined list (as opposed to "inline" which auto-completes in the text field itself, or "both" which does both).
Why aria-expanded on the input
The aria-expanded attribute on the combobox input communicates whether the listbox popup is currently visible. Screen readers announce "collapsed" or "expanded" so users know whether they can arrow through options. Without this attribute, users have no way to know if the dropdown is open or closed, leading to confusion when arrow keys either do nothing or start navigating unexpectedly.
Why aria-controls linking to the listbox
The aria-controls attribute on the input points to the listbox's id, establishing a programmatic relationship between the two elements. This tells assistive technology that operating the input controls the content of the listbox. Some screen readers use this relationship to let users jump directly to the controlled element.
Why announce the result count via a live region
Screen readers can't count how many options remain after filtering — they only see the option that aria-activedescendant currently points to. A live region with aria-live="polite" announces messages like "5 results available" after each filter operation. Without this, a screen reader user has no idea whether their search matched zero items, one item, or dozens, and must arrow through the entire list to find out.
// 04 · keyboard interaction
Keyboard Interaction
| Key | Action |
|---|---|
| Type | Filters the listbox to show matching options. Opens the listbox if it was closed. |
| Arrow Down | Opens the listbox if closed. Moves virtual focus to the next option. Wraps from last to first. |
| Arrow Up | Moves virtual focus to the previous option. Wraps from first to last. |
| Enter | Selects the currently highlighted option, fills the input, and closes the listbox |
| Escape | Closes the listbox without selecting an option. Does not clear the input value. |
| Home | Moves virtual focus to the first option in the listbox (when open) |
| End | Moves virtual focus to the last option in the listbox (when open) |
| Tab | Selects the current option (if one is highlighted), closes the listbox, and moves focus to the next form field |
aria-activedescendant attribute creates "virtual focus" — the screen reader announces the active option, but the keyboard cursor stays in the text field. This lets users continue typing to refine their search while simultaneously reviewing suggestions with arrow keys.
// 05 · wcag 2.2 success criteria
WCAG 2.2 Success Criteria
This pattern satisfies the following WCAG 2.2 success criteria:
-
4.1.2 Name, Role, Value
Level A
— The input exposes
role="combobox", the list exposesrole="listbox"withrole="option"children. States likearia-expandedandaria-selectedare programmatically updated. - 2.1.1 Keyboard Level A — Full keyboard operation: typing to filter, arrow keys to navigate, Enter to select, Escape to close, Home/End for first/last option.
- 2.1.2 No Keyboard Trap Level A — Tab and Escape both exit the widget. Users can always leave the combobox and continue navigating the page.
-
1.3.1 Info and Relationships
Level A
— The
<label>is associated with the input viafor/id.aria-controlslinks the combobox to its listbox.aria-activedescendantestablishes the virtual focus relationship. -
2.4.3 Focus Order
Level A
— Focus stays in the input throughout the interaction. Virtual focus via
aria-activedescendantkeeps the logical focus order intact without disrupting the page's tab sequence. -
4.1.3 Status Messages
Level AA
— The result count (e.g. "5 results available") is announced via an
aria-live="polite"region without moving focus. - 2.4.13 Focus Appearance Level AAA — The input has a visible 2px focus outline. The active option is highlighted with a contrasting background color for clear visual indication.
-
2.5.8 Target Size (Minimum)
Level AA
— Both the input and each option meet the 24x24px minimum target size via
min-height: 2.75rem. -
3.3.2 Labels or Instructions
Level A
— A visible
<label>element is associated with the input, providing a persistent text label that identifies the field's purpose.
// 06 · screen reader behavior
Screen Reader Behavior
When navigating to the combobox
- NVDA: "Choose a country, combobox, autocomplete list, collapsed" — announces the label, role, autocomplete type, and expanded state
- JAWS: "Choose a country edit combo, type text" — announces label and input type
- VoiceOver: "Choose a country, combobox, has autocomplete" — announces label, role, and autocomplete capability
When typing to filter
- NVDA: After typing "fra" — "3 results available" (from the live region) — announces the count without interrupting typing
- JAWS: After filtering — announces result count from the live region, then individual options when arrowed to
- VoiceOver: After typing — "3 results available" (from the live region) — politely announces after a brief pause
When arrowing through options
- NVDA: Arrow Down — "France, 1 of 3" — announces option text and position via
aria-activedescendant - JAWS: Arrow Down — "France 1 of 3" — announces option name and position
- VoiceOver: Arrow Down — "France, text, 1 of 3" — announces the option content and position
When selecting an option
- NVDA: Enter — "France, selected" — confirms the selection and closes the listbox
- JAWS: Enter — "France" — fills the input with the selected value
- VoiceOver: Enter — "France, collapsed" — confirms selection and announces the collapsed state
// 07 · common mistakes
Common Mistakes
<select> for autocomplete
The <select> element doesn't support typing to filter options. It only allows selecting from a fixed list. For an autocomplete experience where users type to narrow results, you need a role="combobox" input paired with a role="listbox". Trying to hack a <select> into an autocomplete breaks both the visual and assistive technology experience.
aria-activedescendant
Moving real DOM focus to list options when arrowing through them pulls the cursor out of the input. The user loses their typing position and can't refine their search without tabbing back. aria-activedescendant creates virtual focus — the screen reader announces the active option while real focus stays in the input, preserving the cursor position.
aria-expanded
Without aria-expanded on the combobox input, screen readers can't tell users whether the suggestion list is visible. Users won't know if arrow keys will navigate options or do nothing. Always toggle aria-expanded between "true" and "false" when showing and hiding the listbox.
aria-live="polite" region that updates with the match count on each filter.
role="listbox" without role="option" children
A listbox requires role="option" on each child item. Using plain <div> or <li> elements without the option role means screen readers see the listbox as empty — there are no selectable items. Every child of the listbox that the user can select must have role="option" and a unique id for aria-activedescendant to reference.
// 08 · native html vs. aria
Native HTML vs. ARIA
HTML provides <datalist> for basic autocomplete, but it has severe limitations. This table compares what native HTML offers versus what ARIA is required to build a fully accessible combobox.
| Feature | Native HTML | ARIA Requirement |
|---|---|---|
| Input element | <input> — provides text entry |
role="combobox" + aria-autocomplete="list" |
| Suggestions list | <datalist> — very limited styling and behavior |
role="listbox" + role="option" children |
| Filtering | None with <datalist> — browser controls filtering |
JavaScript filter logic required |
| Open/closed state | None — browser manages <datalist> visibility |
aria-expanded="true/false" on the input |
| Active option | None — no programmatic way to track active suggestion | aria-activedescendant pointing to option id |
| Selection state | None | aria-selected="true" on the active option |
| Input-to-list link | list attribute for <datalist> |
aria-controls pointing to listbox id |
| Label | <label for> — works natively |
Same — native <label> works fine with combobox role |
| Result count | None | aria-live region for count announcements |
<datalist> exists as a native option, it offers no control over styling, filtering behavior, keyboard navigation, or screen reader announcements. For any production autocomplete that needs consistent behavior across browsers and assistive technologies, role="combobox" with a custom listbox is the only viable approach. The <label> element remains the one part where native HTML does the job perfectly.