Dropdown Dilemmas: Building a WAI-ARIA Compliant Autocomplete and Combobox in React
Master the complexities of ARIA attributes, roving tabindex, arrow key navigation, and async filtering to build a production-ready, fully accessible React combobox.
Building a custom combobox or autocomplete component is a classic rite of passage in front-end development. At first glance, it seems simple: an <input> field coupled with a dropdown list of suggestions. However, once you peek under the hood, you quickly realize you are building a miniature operating system widget.
To build a truly production-grade component, you must account for asynchronous data fetching, keyboard navigation, focus management, and strict WAI-ARIA compliance. If you fail on any of these fronts, you risk alienating screen reader users and keyboard-only power users.
In this guide, we will break down the mechanics of the WAI-ARIA Combobox pattern, dissect complex keyboard interactions, and build a fully accessible, type-safe React combobox component from scratch using TypeScript.
Understanding the Anatomy of a WAI-ARIA Combobox
The WAI-ARIA specification defines a combobox as a composite widget that combines a single-line text input with a popup (usually a listbox) that helps the user set the value of the input.
To make this accessible to assistive technologies, we must establish a clear programmatic relationship between the input, the wrapper, and the popup list. Here are the core ARIA attributes you need:
role="combobox": Applied to the input itself (or a wrapper container, depending on the exact pattern variant, though putting it directly on the input or managingaria-expandedon the input is standard for modern implementations).aria-expanded: A boolean (trueorfalse) indicating whether the popup list is currently visible.aria-haspopup: Set to"listbox"to inform screen readers that activating the input opens a listbox popup.aria-controls: Points to theidof the popup listbox, linking the input’s focus state directly to the dropdown content.aria-activedescendant: Points to theidof the currently highlighted option inside the listbox, allowing screen readers to announce the active option without shifting DOM focus away from the input.role="listbox": Applied to the dropdown container.role="option": Applied to each item inside the listbox, accompanied byaria-selectedto indicate selection states.
Note on Focus Management: A common anti-pattern is moving DOM focus from the input to the list items when arrowing down. In a combobox, focus remains on the input at all times. We use
aria-activedescendantto visually and programmatically signal the active option while the input maintains focus.
The Keyboard Interaction Contract
Keyboard accessibility isn’t an afterthought; it is the core user experience for many. Users expect a predictable set of key bindings when interacting with a combobox:
ArrowDown/ArrowUp: If the popup is closed, opens the popup and optionally highlights the first or last item. If open, cycles focus/highlighting through the list options.Enter: Selects the currently highlighted option and closes the popup.Escape: Closes the popup. If the popup is already closed, optionally clears the input value.Tab: Closes the popup and commits the current value or selection, allowing normal focus progression to the next focusable element on the page.- Typing (
Char keys): Filters the list dynamically based on the input value.
Building the Production-Ready React Component
Let’s put these principles into action. Below is a complete, TypeScript-powered React component that handles async filtering, keyboard navigation, aria-activedescendant, and outside clicks.
The TypeScript Implementation
import React, {
useState,
useRef,
useEffect,
useId,
KeyboardEvent,
ChangeEvent,
} from "";
export interface Option {
id: string;
label: string;
value: string;
}
interface ComboboxProps {
options: Option[];
value: string;
onChange: (value: string) => void;
onSelect: (option: Option) => void;
placeholder?: string;
isLoading?: boolean;
label: string;
}
export const AccessibleCombobox: React.FC<ComboboxProps> = ({
options,
value,
onChange,
onSelect,
placeholder = "Search...",
isLoading = false,
label,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const inputRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const uniqueId = useId();
const listboxId = `combobox-listbox-${uniqueId}`;
const labelId = `combobox-label-${uniqueId}`;
// Handle outside clicks to close dropdown
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);
}, []);
// Reset active index when options change or dropdown closes
useEffect(() => {
if (!isOpen) {
setActiveIndex(-1);
} else if (options.length > 0 && activeIndex >= options.length) {
setActiveIndex(0);
}
}, [options, isOpen, activeIndex]);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
if (!isOpen) setIsOpen(true);
setActiveIndex(-1);
};
const handleKeyDown = (e: 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]) {
onSelect(options[activeIndex]);
setIsOpen(false);
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
setActiveIndex(-1);
break;
case "Tab":
setIsOpen(false);
break;
default:
break;
}
};
const handleOptionClick = (option: Option) => {
onSelect(option);
setIsOpen(false);
inputRef.current?.focus();
};
const activeOptionId =
isOpen && activeIndex >= 0 && options[activeIndex]
? `option-${uniqueId}-${options[activeIndex].id}`
: undefined;
return (
<div className="relative w-full max-w-sm">
<label
id={labelId}
htmlFor={`input-${uniqueId}`}
className="block text-sm font-medium text-gray-700 mb-1"
>
{label}
</label>
<div className="relative">
<input
ref={inputRef}
id={`input-${uniqueId}`}
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-activedescendant={activeOptionId}
aria-labelledby={labelId}
type="text"
value={value}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
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-blue-500 focus:border-blue-500"
/>
{isOpen && (
<ul
ref={listboxRef}
id={listboxId}
role="listbox"
aria-labelledby={labelId}
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">Loading...</li>
) : options.length === 0 ? (
<li className="px-4 py-2 text-sm text-gray-500">No results found</li>
) : (
options.map((option, index) => {
const isSelected = value === option.label;
const isActive = index === activeIndex;
const optionId = `option-${uniqueId}-${option.id}`;
return (
<li
key={option.id}
id={optionId}
role="option"
aria-selected={isSelected}
onClick={() => handleOptionClick(option)}
onMouseEnter={() => setActiveIndex(index)}
className={`px-4 py-2 text-sm cursor-pointer ${
isActive ? "bg-blue-600 text-white" : "text-gray-900"
} ${isSelected && !isActive ? "bg-blue-50" : ""}`}
>
{option.label}
</li>
);
})
)}
</ul>
)}
</div>
</div>
);
};
Handling Asynchronous Filtering and Race Conditions
When dealing with autocomplete fields that fetch suggestions from an API, network latency introduces complexity. If a user types quickly, network requests can resolve out of order, leading to stale suggestions overwriting newer ones.
To handle asynchronous filtering gracefully, pair your combobox with a debounced search hook or utilize an AbortController to cancel pending fetch requests:
import { useState, useEffect } from 'react';
export function useAsyncAutocomplete(fetcher: (query: string, signal: AbortSignal) => Promise<Option[]>) {
const [query, setQuery] = useState('');
const [options, setOptions] = useState<Option[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const controller = new AbortController();
if (!query.trim()) {
setOptions([]);
setIsLoading(false);
return;
}
setIsLoading(true);
const timer = setTimeout(async () => {
try {
const results = await fetcher(query, controller.signal);
setOptions(results);
} catch (error: any) {
if (error.name !== 'AbortError') {
console.error('Failed to fetch autocomplete options', error);
}
} finally {
setIsLoading(false);
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query, fetcher]);
return { query, setQuery, options, isLoading };
}
By integrating AbortController, every keystroke that triggers a new query immediately cancels the inflight request of the previous keystroke, preserving network bandwidth and preventing state corruption.
Testing Your Implementation with Screen Readers
Automated accessibility testing tools like axe-core will catch missing aria-label attributes or broken ID references, but they cannot evaluate experiential accessibility. Always test your combobox manually using real assistive tech:
- macOS: VoiceOver (
Cmd + F5) - Windows: NVDA (Free and open source)
- Mobile: TalkBack (Android) or VoiceOver (iOS)
Verify that:
- Focusing the input announces the label and indicates it is an expanded/collapsed combobox.
- Pressing
ArrowDownannounces the first item in the list without shifting keyboard focus. - Selecting an item updates the input value and cleanly closes the popup.
Conclusion
Building a custom combobox in React is an exercise in balancing design flexibility with strict behavioral contracts. By respecting the WAI-ARIA specification, meticulously managing aria-activedescendant, and supporting robust keyboard navigation patterns, you ensure that your components are delightful and inclusive for every user.