Building an Accessible Autocomplete in React: Combobox Patterns and Keyboard Navigation
A practical, implementation-focused guide to building a fully accessible React autocomplete combobox component with robust keyboard navigation and ARIA attributes.
Building an Accessible Autocomplete in React: Combobox Patterns and Keyboard Navigation
Building an autocomplete or combobox component seems straightforward at first glance: an input field, a dropdown list of suggestions, and some filtering logic. However, making that component accessible to screen reader users and navigable via keyboard-only interactions introduces significant complexity.
In this guide, we will build a production-grade, accessible Autocomplete component in React using TypeScript. We will focus on the W3C ARIA Combobox pattern, proper state management for aria-expanded and aria-activedescendant, and robust arrow-key navigation.
The Anatomy of an Accessible Combobox
A combobox is an input widget that controls another element, such as a listbox, that can dynamically pop up to help the user set the value of the input. According to the WAI-ARIA Authoring Practices Guide (APG), a robust combobox requires a clear separation of roles:
- The Combobox Wrapper: An element with
role="combobox"(or placed directly on the input depending on the exact pattern version) that orchestrates the state. - The Input: Where the user types. It requires attributes like
aria-expanded,aria-autocomplete="list", andaria-controlspointing to the popup ID. - The Popup Listbox: An element with
role="listbox"containing options withrole="option".
Choosing Your Pattern: aria-activedescendant vs. Focus Management
There are two primary patterns for keyboard navigation in autocomplete lists:
aria-activedescendantPattern: Focus remains on the text input at all times. As the user presses the Down/Up arrows, an ID reference is passed toaria-activedescendanton the input, and the active option is visually highlighted via CSS. This is ideal because the user never loses their cursor position in the input field.- DOM Focus Management Pattern: Focus moves from the input directly into the listbox items. This can be cumbersome for typing because every arrow key shifts focus away from the input buffer.
We will implement the aria-activedescendant pattern for the smoothest user experience.
Setting Up the TypeScript Interfaces
Let’s define the props and state types for our component. We will accept a generic item type so our autocomplete can handle everything from simple strings to complex user objects.
import React, {
useState,
useRef,
useEffect,
useId,
KeyboardEvent,
ChangeEvent,
} from 'react';
export interface AutocompleteItem {
id: string;
label: string;
[key: string]: any;
}
interface AccessibleAutocompleteProps<T extends AutocompleteItem> {
items: T[];
value: string;
onChange: (value: string) => void;
onSelect: (item: T) => void;
placeholder?: string;
label: string;
}
Building the Component
Let’s construct the core component state, refs, and accessibility attributes.
export function AccessibleAutocomplete<T extends AutocompleteItem>({
items,
value,
onChange,
onSelect,
placeholder,
label,
}: AccessibleAutocompleteProps<T>) {
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const inputRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const uniqueId = useId();
const listboxId = `autocomplete-listbox-${uniqueId}`;
const inputId = `autocomplete-input-${uniqueId}`;
// Filter items based on input value
const filteredItems = items.filter((item) =>
item.label.toLowerCase().includes(value.toLowerCase())
);
// Reset highlight index when filtered items change
useEffect(() => {
setHighlightedIndex(-1);
}, [value]);
// ... Keyboard handlers go here
return (
<div className="relative w-full max-w-sm">
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
{/* Combobox container */}
<div className="relative">
<input
ref={inputRef}
id={inputId}
type="text"
role="combobox"
aria-expanded={isOpen && filteredItems.length > 0}
aria-autocomplete="list"
aria-controls={listboxId}
aria-activedescendant={
highlightedIndex >= 0 && isOpen
? `${listboxId}-item-${highlightedIndex}`
: undefined
}
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
setIsOpen(true);
}}
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"
/>
{isOpen && filteredItems.length > 0 && (
<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"
>
{filteredItems.map((item, index) => {
const isHighlighted = index === highlightedIndex;
return (
<li
key={item.id}
id={`${listboxId}-item-${index}`}
role="option"
aria-selected={isHighlighted}
onClick={() => {
onSelect(item);
setIsOpen(false);
setHighlightedIndex(-1);
}}
className={`px-3 py-2 cursor-pointer text-sm ${
isHighlighted ? 'bg-blue-600 text-white' : 'text-gray-900 hover:bg-gray-100'
}`}
>
{item.label}
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
Implementing Robust Keyboard Navigation
Keyboard accessibility requires intercepting specific keydown events on the input. Users expect standard behaviors:
- Arrow Down / Arrow Up: Open the listbox (if closed) and move the highlight index up or down.
- Enter: Select the currently highlighted item.
- Escape: Close the listbox.
Add the following onKeyDown handler to the input element:
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setHighlightedIndex((prev) =>
prev < filteredItems.length - 1 ? prev + 1 : 0
);
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setHighlightedIndex((prev) =>
prev > 0 ? prev - 1 : filteredItems.length - 1
);
}
break;
case 'Enter':
e.preventDefault();
if (isOpen && highlightedIndex >= 0 && filteredItems[highlightedIndex]) {
onSelect(filteredItems[highlightedIndex]);
setIsOpen(false);
setHighlightedIndex(-1);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
setHighlightedIndex(-1);
break;
case 'Tab':
setIsOpen(false);
break;
default:
break;
}
};
Don’t forget to wire onKeyDown={handleKeyDown} directly onto the <input> element.
Handling Edge Cases and Polishing UX
1. Click Outside to Close
When a user clicks anywhere outside the autocomplete component, the dropdown list should close automatically. We can implement this with a simple document event listener inside a useEffect hook:
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 markup inside <div ref={containerRef}> to make this work.
2. Auto-Scrolling Highlighted Items
When navigating with Arrow Down and Arrow Up, if the list is long, the highlighted item might fall outside the visible scroll area of the listbox. We can ensure the active item scrolls into view automatically:
useEffect({
if (!isOpen || highlightedIndex < 0 || !listboxRef.current) return;
const listElement = listboxRef.current;
const itemElement = listElement.children[highlightedIndex] as HTMLElement;
if (itemElement) {
const itemTop = itemElement.offsetTop;
const itemBottom = itemTop + itemElement.offsetHeight;
const viewTop = listElement.scrollTop;
const viewBottom = viewTop + listElement.clientHeight;
if (itemTop < viewTop) {
listElement.scrollTop = itemTop;
} else if (itemBottom > viewBottom) {
listElement.scrollTop = itemBottom - listElement.clientHeight;
}
}
}, [highlightedIndex, isOpen]);
Conclusion
Creating an accessible combobox requires careful synchronization between visual state, keyboard focus, and ARIA attributes. By leveraging the aria-activedescendant pattern, we maintain standard input cursor behavior while offering seamless keyboard navigation through dropdown suggestions.
When building design systems, always test your components with screen readers (such as VoiceOver on macOS or NVDA on Windows) alongside keyboard-only navigation to ensure all users receive a first-class experience.