Building an Accessible Combobox in React: Async Autocomplete and Keyboard Navigation
Master the complexities of building a production-grade, accessible React combobox featuring asynchronous search, full ARIA attributes, and robust keyboard navigation.
Building an Accessible Combobox in React: Async Autocomplete and Keyboard Navigation
Building a custom select dropdown is one thing, but building a combobox—a composite widget combining a text input and a popup listbox—is a different beast entirely. It sits at the intersection of text input behavior, list management, asynchronous data fetching, and strict accessibility (a11y) guidelines.
According to the WAI-ARIA Authoring Practices Guide (APG), a combobox must support specific keyboard interactions, live region announcements for asynchronous loading states, and precise ARIA attribute wiring so screen reader users never lose their place.
In this comprehensive guide, we’ll build a production-ready, highly accessible, async-searching Combobox component in React using TypeScript.
Understanding the Combobox Anatomy and ARIA Contract
Before diving into code, let’s look at the core contract between our component and assistive technologies. A combobox consists of three primary elements:
- The Combobox Wrapper: Usually a container holding the input and popup.
- The Input Element (
role="combobox"): Where the user types their query. - The Popup Listbox (
role="listbox"): Where matching options appear.
Critical ARIA Attributes
aria-expanded="true|false": Informs the screen reader whether the popup listbox is currently open.aria-haspopup="listbox": Indicates that clicking or typing will trigger a listbox popup.aria-controls="listbox-id": Associates the input directly with the ID of the listbox element.aria-autocomplete="list": Tells the assistive tech that typing filters a list of values.aria-activedescendant="option-id": Dynamically points to the ID of the currently highlighted option in the listbox, allowing screen readers to announce focused items without shifting real DOM focus away from the input.
Setting Up Types and State
Let’s start by defining our TypeScript interfaces. We need to handle item selection, async loading states, keyboard tracking, and input values.
import React, {
useState,
useRef,
useEffect,
useId,
useCallback,
KeyboardEvent,
ChangeEvent,
} from 'react';
export interface ComboboxOption {
id: string;
label: string;
value: string;
}
interface ComboboxProps {
onSearch: (query: string) => Promise<ComboboxOption[]>;
onSelect: (option: ComboboxOption | null) => void;
placeholder?: string;
label: string;
}
Implementing the Component Structure
Next, we’ll construct our core component, hooking up refs for DOM measurement, state management for our results, and IDs for accessibility linkages.
export const AccessibleCombobox: React.FC<ComboboxProps> = ({
onSearch,
onSelect,
placeholder = 'Search...',
label,
}) => {
const [query, setQuery] = useState('');
const [options, setOptions] = useState<ComboboxOption[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const [selectedOption, setSelectedOption] = useState<ComboboxOption | null>(null);
const [announcement, setAnnouncement] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const comboboxId = useId();
const listboxId = `${comboboxId}-listbox`;
const statusId = `${comboboxId}-status`;
// ... implementation details below
return (
<div className="relative w-full max-w-md">
{/* Screen reader live region for async updates */}
<div
id={statusId}
role="status"
aria-live="polite"
className="sr-only"
>
{announcement}
</div>
<label htmlFor={`${comboboxId}-input"`} className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
{/* Combobox container */}
<div className="relative">
<input
ref={inputRef}
id={`${comboboxId}-input`}
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={
activeIndex >= 0 ? `${comboboxId}-option-${activeIndex}` : undefined
}
value={query}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => { if (options.length > 0) setIsOpen(true); }}
placeholder={placeholder}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{isOpen && (
<ul
ref={listboxRef}
id={listboxId}
role="listbox"
className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto"
>
{isLoading ? (
<li className="px-4 py-2 text-gray-500 text-sm" role="option" aria-selected="false">
Loading results...
</li>
) : options.length === 0 ? (
<li className="px-4 py-2 text-gray-500 text-sm" role="option" aria-selected="false">
No results found
</li>
) : (
options.map((option, index) => {
const isSelected = selectedOption?.id === option.id;
const isActive = activeIndex === index;
return (
<li
key={option.id}
id={`${comboboxId}-option-${index}`}
role="option"
aria-selected={isSelected}
onClick={() => handleOptionSelect(option)}
className={`px-4 py-2 text-sm cursor-pointer ${
isActive ? 'bg-blue-600 text-white' : 'text-gray-900'
}`}
>
{option.label}
</li>
);
})
)}
</ul>
)}
</div>
</div>
);
};
Handling Asynchronous Search and Live Announcements
When fetching asynchronous data, screen readers need explicit feedback. If a user types and results take 500ms to arrive, they will have no idea what is happening unless we use a aria-live region.
We implement a debounced search handler that updates our options and sets an announcement string for our screen reader container.
useEffect({
// Debounce hook setup or direct timeout implementation
const timer = setTimeout(async () => {
if (!query.trim() || (selectedOption && query === selectedOption.label)) {
setOptions([]);
setIsOpen(false);
return;
}
setIsLoading(true);
setIsOpen(true);
setAnnouncement('Fetching results...');
try {
const results = await onSearch(query);
setOptions(results);
setActiveIndex(-1);
setIsLoading(false);
if (results.length === 0) {
setAnnouncement('No results found.');
} else {
setAnnouncement(`${results.length} results available. Use up and down arrow keys to navigate.`);
}
} catch (error) {
setIsLoading(false);
setOptions([]);
setAnnouncement('Error fetching results.');
}
}, 300);
return () => clearTimeout(timer);
}, [query, onSearch, selectedOption]);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
if (selectedOption) {
setSelectedOption(null);
onSelect(null);
}
};
Implementing Robust Keyboard Navigation
Keyboard navigation for a combobox must adhere to standard UI expectations:
ArrowDown/ArrowUp: Moves focus through the options list, wrapping or stopping at boundaries, and opens the list if closed.Enter: Selects the currently active option and closes the list.Escape: Closes the popup listbox. If already closed, clears the input.Home/End: Moves the active option to the first or last item respectively (optional but helpful).
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (!isOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
if (options.length > 0) {
setIsOpen(true);
e.preventDefault();
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0
);
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1));
break;
case 'Enter':
e.preventDefault();
if (activeIndex >= 0 && options[activeIndex]) {
handleOptionSelect(options[activeIndex]);
}
break;
case 'Escape':
e.preventDefault();
if (isOpen) {
setIsOpen(false);
setActiveIndex(-1);
} else {
setQuery('');
setSelectedOption(null);
onSelect(null);
}
break;
case 'Home':
if (isOpen && options.length > 0) {
e.preventDefault();
setActiveIndex(0);
}
break;
case 'End':
if (isOpen && options.length > 0) {
e.preventDefault();
setActiveIndex(options.length - 1);
}
break;
default:
break;
}
};
const handleOptionSelect = (option: ComboboxOption) => {
setSelectedOption(option);
setQuery(option.label);
setIsOpen(false);
setActiveIndex(-1);
setOptions([]);
onSelect(option);
inputRef.current?.focus();
};
Handling Outside Clicks
To ensure the popup closes when a user clicks anywhere outside the combobox component, we attach a document-level click listener.
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
Wrap the entire component return block in a div referencing containerRef:
return (
<div ref={containerRef} className="relative w-full max-w-md">
{/* ... rest of component */}
</div>
);
Putting It All Together: Example Usage
Here is how you can consume our AccessibleCombobox component with a simulated mock async API call:
const mockApiSearch = async (query: string): Promise<ComboboxOption[]> => {
// Simulate network latency
await new Promise((resolve) => setTimeout(resolve, 400));
const mockDatabase = [
{ id: '1', label: 'React', value: 'react' },
{ id: '2', label: 'ReactDOM', value: 'react-dom' },
{ id: '3', label: 'React Native', value: 'react-native' },
{ id: '4', label: 'Redux', value: 'redux' },
{ id: '5', label: 'TypeScript', value: 'typescript' },
];
return mockDatabase.filter((item) =>
item.label.toLowerCase().includes(query.toLowerCase())
);
};
export default function App() {
return (
<div className="p-8">
<AccessibleCombobox
label="Framework or Library"
placeholder="Type to search technologies..."
onSearch={mockApiSearch}
onSelect={(option) => console.log('Selected:', option)}
/>
</div>
);
}
Conclusion
Building an accessible combobox requires meticulous attention to state synchronization, keyboard event handling, and ARIA semantics. By maintaining a clean contract between aria-activedescendant and your listbox state, providing polite screen reader announcements during async requests, and handling robust keyboard shortcuts (Home, End, Escape, Arrows), you create a seamless experience for every user, regardless of how they navigate your application.