Unlocking the Combobox: Building a Fully Accessible Autocomplete in React
A comprehensive, step-by-step guide to building a fully accessible, keyboard-navigable autocomplete combobox in React and TypeScript using ARIA patterns and async debouncing.
Unlocking the Combobox: Building a Fully Accessible Autocomplete in React
The combobox is arguably one of the most complex UI components in web development. At its core, it marries a text input with a popup popup list—typically a listbox or a grid—allowing users to filter a large dataset quickly. However, when you introduce screen readers, strict keyboard navigation rules, asynchronous data fetching, and dynamic popup sizing, building a robust combobox becomes a formidable challenge.
In this guide, we will build a production-ready, highly accessible Autocomplete Combobox in React and TypeScript. We will adhere strictly to the WAI-ARIA Authoring Practices Guide (APG) for the Combobox pattern, explore state management, implement async debounced searching, and conquer complex keyboard interactions.
1. Understanding the ARIA Combobox Pattern
Before writing any code, we must understand the semantic contract between our components and assistive technologies (AT). According to the WAI-ARIA 1.2 spec, a combobox widget relies on specific roles, states, and properties:
role="combobox": Placed on the controlling element (usually the<input>). It informs the screen reader that this input controls a popup.aria-expanded: A boolean (trueorfalse) indicating whether the popup list is currently visible.aria-haspopup: Set to"listbox"to declare the nature of the popup.aria-controls: Points to theidof the popup listbox element.aria-activedescendant: Points to theidof the currently focused option inside the listbox. This is crucial for keeping screen readers synchronized with visual focus without moving the browser’s raw DOM focus away from the input.role="listbox": Placed on the container holding the options.role="option": Placed on each selectable item inside the listbox.
Choosing Your Focus Strategy: aria-activedescendant vs. Roving Tabindex
There are two primary patterns for managing focus inside a combobox listbox:
- Roving Tabindex: Moving actual browser focus (
document.activeElement) to the active option element. aria-activedescendant: Keeping focus on the<input>element while using thearia-activedescendantattribute to communicate the “virtual focus” to screen readers.
For an autocomplete combobox where the user is actively typing in an input, aria-activedescendant is the preferred pattern. It ensures the user never loses their typing cursor position while navigating the options via arrow keys.
2. Setting Up the TypeScript Interfaces
Let’s define the core TypeScript types for our component. We want a generic component that can accept any data shape, provided we can extract a display string and a unique identifier.
import React, { ReactNode } from 'react';
export interface ComboboxOption {
id: string;
label: string;
[key: string]: any;
}
export interface AccessibleComboboxProps<T extends ComboboxOption> {
options: T[];
value: T | null;
onChange: (value: T | null) => void;
onSearch: (query: string) => void;
isLoading?: boolean;
placeholder?: string;
label: string;
renderOption?: (option: T, isSelected: boolean, isHighlighted: boolean) => ReactNode;
}
3. Implementing the React Component
Let’s build the component step-by-step. We will manage state for the input value, open/closed status of the dropdown, and the index of the currently highlighted option (activeIndex).
import React, { useState, useRef, useEffect, useId, useTransition } from 'react';
export function AccessibleCombobox<T extends ComboboxOption>({
options,
value,
onChange,
onSearch,
isLoading = false,
placeholder = 'Search...',
label,
renderOption,
}: AccessibleComboboxProps<T>) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState(value ? value.label : '');
const [activeIndex, setActiveIndex] = useState<number>(-1);
const [isPending, startTransition] = useTransition();
const comboboxId = useId();
const listboxId = `${comboboxId}-listbox`;
const inputRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
// Sync local query when controlled value changes externally
useEffect(() => {
if (value) {
setQuery(value.label);
} else if (!isOpen) {
setQuery('');
}
}, [value, isOpen]);
// Reset active index when options change
useEffect(() => {
setActiveIndex(-1);
}, [options]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newQuery = e.target.value;
setQuery(newQuery);
if (!isOpen) setIsOpen(true);
startTransition(() => {
onSearch(newQuery);
});
};
const handleSelectOption = (option: T) => {
onChange(option);
setQuery(option.label);
setIsOpen(false);
setActiveIndex(-1);
inputRef.current?.focus();
};
// ... Keyboard navigation logic goes here
return (
<div className="relative w-full max-w-sm">
<label htmlFor={comboboxId} className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
<div className="relative">
<input
ref={inputRef}
id={comboboxId}
type="text"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={
isOpen && activeIndex >= 0 ? `${comboboxId}-option-${activeIndex}` : undefined
}
value={query}
onChange={handleInputChange}
onFocus={() => 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-indigo-500"
/>
</div>
{isOpen && (
<ul
ref={listboxRef}
id={listboxId}
role="listbox"
aria-label={label}
className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto focus:outline-none"
>
{isLoading ? (
<li className="px-4 py-2 text-sm text-gray-500" role="status">
Loading...
</li>
) : options.length === 0 ? (
<li className="px-4 py-2 text-sm text-gray-500" role="option" aria-selected="false">
No results found
</li>
) : (
options.options.map((option, index) => {
const isSelected = value?.id === option.id;
const isHighlighted = activeIndex === index;
const optionId = `${comboboxId}-option-${index}`;
return (
<li
key={option.id}
id={optionId}
role="option"
aria-selected={isSelected}
onClick={() => handleSelectOption(option)}
onMouseEnter={() => setActiveIndex(index)}
className={`px-4 py-2 text-sm cursor-pointer ${
isHighlighted ? 'bg-indigo-600 text-white' : 'text-gray-900'
} ${isSelected ? 'font-semibold' : ''}`}
>
{renderOption ? renderOption(option, isSelected, isHighlighted) : option.label}
</li>
);
})
)}
</ul>
)}
</div>
);
}
4. Robust Keyboard Interactions
Keyboard accessibility is where most custom comboboxes fail. A keyboard user expects standard native behavior: Arrow keys move through items, Enter selects the highlighted item, Escape closes the menu, and Home/End navigate boundaries.
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 < options.length - 1 ? prev + 1 : 0));
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setActiveIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1));
}
break;
case 'Enter':
e.preventDefault();
if (isOpen && activeIndex >= 0 && options[activeIndex]) {
handleSelectOption(options[activeIndex]);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
setActiveIndex(-1);
break;
case 'Tab':
// Allow natural tabbing out, but close the dropdown first
setIsOpen(false);
break;
default:
break;
}
};
Pro-Tip: When managing
aria-activedescendant, ensure that your highlighted option scrolls into view automatically when navigating with the arrow keys. If the user hits Arrow Down and the item is out of view, the user experience suffers.
Let’s add an effect to automatically scroll the active list item into view:
effect(() => {
if (isOpen && activeIndex >= 0 && listboxRef.current) {
const activeElement = listboxRef.current.children[activeIndex] as HTMLElement;
if (activeElement) {
activeElement.scrollIntoView({
block: 'nearest',
inline: 'nearest',
});
}
}
}, [activeIndex, isOpen]);
5. Handling Async Search and Debouncing
When fetching autocomplete options from an external API, searching on every single keystroke causes unnecessary network saturation and UI jitter. We need to debounce our search handler.
We can implement a custom useDebounce hook or leverage standard patterns inside our parent container component:
import { useState, useCallback } from 'react';
import debounce from 'lodash.debounce';
import { AccessibleCombobox, ComboboxOption } from './AccessibleCombobox';
interface User extends ComboboxOption {
email: string;
}
export function AsyncUserSearch() {
const [users, setUsers] = useState<User[]>([]);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Fetch function simulating an API call
const fetchUsers = async (query: string) => {
if (!query) {
setUsers([]);
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const response = await fetch(`https://api.example.com/users?q=${encodeURIComponent(query)}`);
const data = await response.json();
setUsers(data);
} catch (error) {
console.error('Failed to fetch users', error);
} finally {
setIsLoading(false);
}
};
// Debounce the search handler using useCallback
const debouncedSearch = useCallback(
debounce((query: string) => fetchUsers(query), 300),
[]
);
return (
<AccessibleCombobox
label="Assignee"
placeholder="Search users by name..."
options={users}
value={selectedUser}
onChange={setSelectedUser}
onSearch={debouncedSearch}
isLoading={isLoading}
renderOption={(user, isSelected, isHighlighted) => (
<div className="flex flex-col">
<span className="font-medium">{user.label}</span>
<span className={`text-xs ${isHighlighted ? 'text-indigo-200' : 'text-gray-500'}`}>
{user.email}
</span>
</div>
)}
/>
);
}
6. Closing on Outside Clicks
A common edge case for dropdown components is closing the popup when the user clicks outside the component boundaries. We can manage this cleanly with a simple ref check effect:
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleOutsideClick = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
setActiveIndex(-1);
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, []);
Conclusion
Building an accessible combobox requires paying close attention to WAI-ARIA specifications, managing complex keyboard event bubbling, and synchronizing virtual focus states using aria-activedescendant.
By leveraging React, TypeScript, and thoughtful architectural patterns, you can provide a seamless autocomplete experience that works effortlessly for both sighted mouse users and screen reader keyboard navigators alike.