Type, Arrow, Select: Building an Accessible Combobox and Autocomplete in React
A step-by-step implementation guide to building a robust, accessible React and TypeScript combobox with ARIA attributes, asynchronous data loading, and comprehensive keyboard navigation.
Type, Arrow, Select: Building an Accessible Combobox and Autocomplete in React
Building a custom combobox or autocomplete component is one of the ultimate tests of frontend craftsmanship. At first glance, it seems simple: an input field coupled with a dropdown list. However, once you factor in accessibility (a11y), asynchronous loading states, precise keyboard navigation, and edge cases like mobile screen readers, the complexity skyrockets.
In this guide, we will build a production-grade, accessible Combobox component from scratch using React and TypeScript. We’ll cover the WAI-ARIA Authoring Practices Guide (APG) standards, manage aria-activedescendant vs. DOM focus, and implement smooth keyboard handling.
Understanding the Combobox Pattern (WAI-ARIA APG)
According to the WAI-ARIA APG, a combobox is an input widget that controls another element, such as a listbox or grid, that can dynamically pop up to help the user set the value of the input.
To make this accessible to screen reader users, our component must satisfy several core requirements:
- Semantic Roles: The input acts as the combobox (
role="combobox"), controlling a popup element withrole="listbox". Each option inside possessesrole="option". - State Attributes: Attributes like
aria-expanded,aria-autocomplete="list", andaria-controlsinform assistive technologies of the current state. - Focus Management: We must decide whether to move actual DOM focus to the listbox items or use
aria-activedescendantto visually track the highlighted item while keeping focus on the input.
aria-activedescendant vs. DOM Focus
- DOM Focus (
focus()on options): Works well for simple listboxes, but in a combobox, users expect to keep typing without losing the text cursor in the input. Moving focus away from the input breaks this mental model. aria-activedescendant: Keeps the focus firmly on the<input>, but points to the ID of the currently active option via an attribute. The screen reader announces the active option whenever it changes. This is the gold standard for comboboxes.
Setting Up Types and State
Let’s start by defining our TypeScript interfaces. We need to support generic item types so our combobox can render anything from a list of user profiles to product search results.
import React, { useState, useRef, useEffect, useId, KeyboardEvent } from 'react';
export interface ComboboxOption {
id: string;
label: string;
[key: string]: any;
}
interface ComboboxProps {
options: ComboboxOption[];
value: ComboboxOption | null;
onChange: (option: ComboboxOption | null) => void;
onInputChange: (value: string) => void;
isLoading?: boolean;
placeholder?: string;
label: string;
}
Next, let’s establish our component skeleton and core state hooks:
export const Combobox: React.FC<ComboboxProps> = ({
options,
value,
onChange,
onInputChange,
isLoading = false,
placeholder = 'Search...',
label,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState(value ? value.label : '');
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}`;
const activeOptionId = activeIndex >= 0 && options[activeIndex]
? `combobox-option-${uniqueId}-${options[activeIndex].id}`
: undefined;
// ... component logic continues
};
Implementing Robust Keyboard Navigation
Keyboard interactions are where most custom comboboxes fail. Users expect specific behaviors:
- Arrow Down / Arrow Up: Opens the listbox (if closed) and moves the active highlight down/up.
- Enter: Selects the currently highlighted option and closes the listbox.
- Escape: Closes the listbox and reverts or clears the input.
- Home / End: Jumps to the first or last option in the list.
Here is how we implement this inside a robust onKeyDown handler:
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) {
setActiveIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1
);
}
break;
case 'Enter':
e.preventDefault();
if (isOpen && activeIndex >= 0 && options[activeIndex]) {
selectOption(options[activeIndex]);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
setActiveIndex(-1);
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;
}
};
Pro Tip: Always call
e.preventDefault()on navigation keys likeArrowDownandHome. Without it, the browser cursor inside the input will jump around or the page will scroll.
Wiring Up ARIA Attributes and Structure
Now we construct the JSX. We must tie the input and the listbox together using aria-expanded, aria-controls, aria-activedescendant, and proper labeling.
const selectOption = (option: ComboboxOption) => {
onChange(option);
setQuery(option.label);
setIsOpen(false);
setActiveIndex(-1);
};
return (
<div className="relative w-full max-w-sm">
{/* External label linked via aria-labelledby */}
<label id={labelId} className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
<div className="relative">
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded={isOpen}
aria-autocomplete="list"
aria-controls={listboxId}
aria-labelledby={labelId}
aria-activedescendant={activeOptionId}
value={query}
onChange={(e) => {
setQuery(e.target.value);
onInputChange(e.target.value);
if (!isOpen) setIsOpen(true);
setActiveIndex(-1);
}}
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-indigo-500"
/>
{isOpen && (
<ul
id={listboxId}
ref={listboxRef}
role="listbox"
aria-label={label}
className="absolute z-10 w-full mt-1 bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none"
>
{isLoading ? (
<li className="cursor-default select-none relative py-2 px-4 text-gray-500">
Loading...
</li>
) : options.length === 0 ? (
<li className="cursor-default select-none relative py-2 px-4 text-gray-500">
No results found
</li>
) : (
options.map((option, index) => {
const isSelected = value?.id === option.id;
const isActive = index === activeIndex;
return (
<li
key={option.id}
id={`combobox-option-${uniqueId}-${option.id}`}
role="option"
aria-selected={isSelected}
onClick={() => selectOption(option)}
onMouseEnter={() => setActiveIndex(index)}
className={`cursor-pointer select-none relative py-2 pl-3 pr-9 ${
isActive ? 'bg-indigo-600 text-white' : 'text-gray-900'
}`}
>
<span className={`block truncate ${isSelected ? 'font-semibold' : 'font-normal'}`}>
{option.label}
</span>
</li>
);
})
)}
</ul>
)}
</div>
</div>
);
Handling Asynchronous Data Loading and Debouncing
When building an autocomplete that fetches data from an API, network latency can introduce jarring UI states (such as race conditions or abrupt layout shifts). To handle this gracefully:
- Debounce the Input: Prevent firing an API request on every single keystroke.
- Loading Indicators: Display a clear loading state inside the listbox dropdown.
- Click Outside Handling: Automatically close the listbox when the user clicks outside the component boundaries.
Here is a quick hook implementation for handling outside clicks:
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
inputRef.current &&
listboxRef.current &&
!inputRef.current.contains(event.target as Node) &&
!listboxRef.current.contains(event.target as Node)
)
{
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
Conclusion
Building an accessible combobox requires going beyond basic React state management. By adhering to WAI-ARIA APG standards, utilizing aria-activedescendant, and carefully orchestrating keyboard events for Home, End, Enter, and Escape, you ensure that all users—regardless of whether they use a mouse, screen reader, or keyboard—enjoy a seamless experience.
Now you have a solid foundation. Feel free to extend this component with multi-select capabilities, virtualization for massive datasets, or custom render props for rich option templates.