Building an Accessible Combobox in React: ARIA Combobox Patterns and Keyboard Navigation
A practical technical guide on building a robust, accessible autocomplete combobox in React using state machines, ARIA attributes, and full keyboard navigation.
Building an Accessible Combobox in React: ARIA Combobox Patterns and Keyboard Navigation
The combobox is one of the most notoriously tricky user interface components to build correctly. It sits at the intersection of a text input and a listbox, requiring simultaneous management of text entry, asynchronous filtering, popover positioning, and complex keyboard navigation.
When built poorly, a combobox is a nightmare for screen reader users and keyboard-only power users. When built correctly—adhering to the WAI-ARIA Authoring Practices Guide (APG)—it provides a seamless, native-feeling experience for everyone.
In this guide, we will build a production-ready, fully accessible autocomplete combobox in React and TypeScript. We’ll break down the state machine, wire up the critical ARIA attributes, and implement robust arrow-key navigation.
Understanding the ARIA Combobox Anatomy
Before writing code, we must understand the contract between our component and assistive technologies. An accessible combobox relies on a relationship between three primary elements:
- The Combobox Input: The text field where the user types. It carries
role="combobox". - The Listbox Popup: The container holding the selectable options. It carries
role="listbox". - The Options: Individual items inside the listbox, carrying
role="option".
The Critical ARIA Attributes
aria-expanded="true|false": Informs the screen reader whether the popup listbox is currently open.aria-haspopup="listbox": Declares that this input triggers a listbox popup.aria-controls="element-id": Points the input to the ID of the listbox popup.aria-autocomplete="list": Tells the assistive tech that typing filters a list of choices.aria-activedescendant="option-id": Points to the ID of the currently focused option in the listbox while focus remains on the input.
Designing the State Machine
A predictable combobox relies on clear state management. We need to track:
query: The current text value inside the input.isOpen: Whether the listbox is visible.activeIndex: The index of the currently highlighted option in the listbox (-1when no option is highlighted).selectedItem: The committed selection.
Let’s define our TypeScript types and state structure:
import React, { useState, useRef, useEffect, useId, useMemo } from 'react';
interface ComboboxProps<T> {
items: T[];
itemToString: (item: T) => string;
onSelect: (item: T) => void;
placeholder?: string;
}
Building the React Component
Below is the complete implementation of our accessible combobox. We’ll use React hooks to manage focus, keyboard events, and ARIA bindings.
export function Combobox<T>({ items, itemToString, onSelect, placeholder }: ComboboxProps<T>) {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const inputRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const comboboxId = useId();
const listboxId = `${comboboxId}-listbox`;
const inputId = `${comboboxId}-input`;
// Filter items based on user input
const filteredItems = useMemo(() => {
if (!query) return items;
return items.filter(item =>
itemToString(item).toLowerCase().includes(query.toLowerCase())
);
}, [items, query, itemToString]);
// Reset active index when filtered items change
useEffect(() => {
setActiveIndex(-1);
}, [query]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
setIsOpen(true);
};
const handleOptionClick = (item: T) => {
setQuery(itemToString(item));
onSelect(item);
setIsOpen(false);
setActiveIndex(-1);
inputRef.current?.focus();
};
// ... Keyboard navigation logic goes here
return (
<div className="combobox-wrapper">
<label htmlFor={inputId} className="combobox-label">
Search Options
</label>
<div className="combobox-input-container">
<input
ref={inputRef}
id={inputId}
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={
activeIndex >= 0 && isOpen ? `${comboboxId}-option-${activeIndex}` : undefined
}
value={query}
onChange={handleInputChange}
placeholder={placeholder}
// Keyboard handlers added in next section
/>
</div>
{isOpen && filteredItems.length > 0 && (
<ul
ref={listboxRef}
id={listboxId}
role="listbox"
className="combobox-listbox"
>
{filteredItems.map((item, index) => {
const isSelected = activeIndex === index;
const optionId = `${comboboxId}-option-${index}`;
return (
<li
key={optionId}
id={optionId}
role="option"
aria-selected={isSelected}
className={`combobox-option ${isSelected ? 'active' : ''}`}
onClick={() => handleOptionClick(item)}
onMouseEnter={() => setActiveIndex(index)}
>
{itemToString(item)}
</li>
);
})}
</ul>
)}
</div>
);
}
Implementing Robust Keyboard Navigation
Keyboard navigation is where custom comboboxes frequently fail. Screen reader and power users rely entirely on specific keystrokes:
ArrowDown: Opens the listbox if closed, or moves the active highlight down to the next option.ArrowUp: Moves the active highlight up to the previous option.Enter: Commits the currently highlighted option, or closes the listbox if none is highlighted.Escape: Closes the listbox and clears the input (or resets to previous state).
Let’s add the onKeyDown handler to our input element:
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setActiveIndex(prev =>
prev < filteredItems.length - 1 ? prev + 1 : 0
);
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setActiveIndex(prev => (prev > 0 ? prev - 1 : filteredItems.length - 1));
}
break;
case 'Enter':
e.preventDefault();
if (isOpen && activeIndex >= 0 && filteredItems[activeIndex]) {
handleOptionClick(filteredItems[activeIndex]);
} else {
setIsOpen(false);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
setActiveIndex(-1);
break;
case 'Tab':
// Close listbox on tab out to allow natural focus flow
setIsOpen(false);
break;
default:
break;
}
};
Why aria-activedescendant Matters
Notice that as the user presses ArrowDown and ArrowUp, focus never leaves the input. Instead, we increment and decrement activeIndex and update aria-activedescendant="combobox-option-3".
Screen readers intercept this attribute change and announce the newly highlighted option without shifting browser focus away from the typing context. This is the gold standard for autocomplete behavior.
Handling Click-Outside and Blur Events
A complete combobox must close its dropdown when the user clicks outside or tabs away from the component entirely.
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
inputRef.current &&
!inputRef.current.contains(event.target as Node) &&
listboxRef.current &&
!listboxRef.current.contains(event.target as Node)
)
{
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
Conclusion
Building an accessible combobox in React requires careful coordination between state, focus management, and WAI-ARIA specifications. By utilizing aria-activedescendant for keyboard traversal and maintaining correct popup states with aria-expanded and aria-controls, you ensure that your component is fully usable by screen reader users and keyboard navigators alike.
Key Takeaways Checklist
- Input has
role="combobox",aria-expanded, andaria-controls. - Popup container has
role="listbox"and a unique ID matching the input’saria-controls. - Options have
role="option"andaria-selectedstates. - Keyboard focus stays on the input while
aria-activedescendanttracks the highlighted option. - Arrow keys, Enter, Escape, and Tab are explicitly handled to manage open/closed states and item selection.