Type, Arrow, Select: Building an Accessible Combobox in React
Learn how to build a fully accessible, WCAG-compliant combobox and autocomplete component in React and TypeScript featuring keyboard navigation and ARIA attributes.
Type, Arrow, Select: Building an Accessible Combobox in React
Building a combobox—often referred to as an autocomplete or typeahead—is one of the most deceptively complex UI challenges in frontend development. At first glance, it looks like a simple text input paired with a dropdown. But once you factor in accessibility (a11y), keyboard navigation, screen reader announcements, focus management, and TypeScript safety, it quickly evolves into a sophisticated state machine.
In this article, we will walk through the architecture of a robust, accessible combobox in React and TypeScript. We will cover listbox expansion, typeahead filtering, aria-activedescendant management, and complete keyboard interactions that adhere strictly to the WAI-ARIA Authoring Practices Guide (APG).
Understanding the Combobox Anatomy
Before writing any code, we need to understand the structural contract between the user, the screen reader, and the DOM. A standard ARIA combobox consists of three main elements:
- The Combobox Input (
role="combobox"): A text input where users type their query. It controls the popup state viaaria-expandedand points to the currently active option viaaria-activedescendant. - The Listbox Popup (
role="listbox"): A container holding the filtered options (role="option"). - The Options (
role="option"): Individual selectable items within the listbox.
The Two Accessibility Patterns
There are two primary ways to manage focus in an ARIA combobox:
aria-activedescendantpattern: Focus remains on the input element at all times, but thearia-activedescendantattribute on the input dynamically updates to match the ID of the currently highlighted option in the listbox. Screen readers announce the active option as it changes.- Focus delegation pattern: Focus moves directly from the input into the listbox items.
For a smooth typing experience where the cursor remains in the text input, the aria-activedescendant pattern is the gold standard. This is the architecture we will build.
Setting Up the TypeScript Interfaces
Let’s start by defining our component props and data types. We want our combobox to be generic so it can accept any data shape, provided we can extract a string label from it.
import React, { useState, useRef, useEffect, useId, KeyboardEvent, ChangeEvent } from 'react';
export interface ComboboxItem {
id: string;
label: string;
[key: string]: any;
}
export interface ComboboxProps<T extends ComboboxItem> {
items: T[];
value: string;
onChange: (value: string) => void;
onSelect: (item: T) => void;
placeholder?: string;
label: string;
}
Building the Core Component State
We need to track several pieces of state: whether the listbox is open, the index of the currently highlighted option, and the filtered subset of items based on user input.
export function Combobox<T extends ComboboxItem>({
items,
value,
onChange,
onSelect,
placeholder = 'Search...',
label,
}: ComboboxProps<T>) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const inputId = useId();
const listboxId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
// Filter items based on the input value
const filteredItems = items.filter((item) =>
item.label.toLowerCase().includes(value.toLowerCase())
);
// Generate unique IDs for options to support aria-activedescendant
const getOptionId = (index: number) => `${listboxId}-option-${index}`;
Implementing Keyboard Navigation
Keyboard accessibility is where most custom dropdowns fail. Users expect specific behaviors when pressing keys like ArrowDown, ArrowUp, Enter, Escape, and Home/End.
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(0);
} else {
setActiveIndex((prev) =>
prev < filteredItems.length - 1 ? prev + 1 : 0
);
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(filteredItems.length - 1);
} else {
setActiveIndex((prev) =>
prev > 0 ? prev - 1 : filteredItems.length - 1
);
}
break;
case 'Enter':
e.preventDefault();
if (isOpen && activeIndex >= 0 && filteredItems[activeIndex]) {
onSelect(filteredItems[activeIndex]);
setIsOpen(false);
setActiveIndex(-1);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
setActiveIndex(-1);
break;
case 'Home':
if (isOpen) {
e.preventDefault();
setActiveIndex(0);
}
break;
case 'End':
if (isOpen) {
e.preventDefault();
setActiveIndex(filteredItems.length - 1);
}
break;
default:
break;
}
};
Managing Focus, Outside Clicks, and Scrolling
When using aria-activedescendant, the highlighted item in the listbox must remain scrolled into view as the user navigates with arrow keys. We also need to close the dropdown when clicking outside the component.
// Scroll active option into view when activeIndex changes
useEffect(() => {
if (isOpen && activeIndex >= 0 && listboxRef.current) {
const activeNode = listboxRef.current.children[activeIndex] as HTMLElement;
if (activeNode) {
activeNode.scrollIntoView({ block: 'nearest' });
}
}
}, [activeIndex, isOpen]);
// Close listbox when clicking outside
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);
setActiveIndex(-1);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
Assembling the JSX and ARIA Attributes
Now we put everything together, ensuring all required ARIA attributes are wired up correctly. Notice how aria-activedescendant dynamically evaluates to the ID of the active option or undefined when nothing is highlighted.
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>
<div className="relative">
<input
ref={inputRef}
id={inputId}
type="text"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={
isOpen && activeIndex >= 0 ? getOptionId(activeIndex) : undefined
}
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
setIsOpen(true);
setActiveIndex(0);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
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"
/>
</div>
{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 focus:outline-none"
>
{filteredItems.map((item, index) => {
const isSelected = item.label === value;
const isActive = index === activeIndex;
return (
<li
id={getOptionId(index)}
key={item.id}
role="option"
aria-selected={isSelected}
className={`px-3 py-2 cursor-pointer text-sm ${
isActive ? 'bg-blue-600 text-white' : 'text-gray-900'
} ${isSelected && !isActive ? 'font-semibold bg-gray-50' : ''}`}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => {
onSelect(item);
setIsOpen(false);
setActiveIndex(-1);
inputRef.current?.focus();
}}
>
{item.label}
</li>
);
})}
</ul>
)}
</div>
);
}
Testing Your Combobox Accessibility
Building accessibility features into a component requires rigorous verification. Here is a quick checklist to ensure your combobox meets accessibility standards:
- Screen Reader Test: Turn on VoiceOver (macOS) or NVDA (Windows). Focus the input, type a search term, and use the Down Arrow key. The screen reader should clearly announce each item and its selection state.
- Keyboard Traversal: Ensure you can open the menu with
ArrowDown, navigate through options, select an item withEnter, and dismiss the menu withEscapewithout touching a mouse.- Focus Ring: Verify that the input maintains a clear, high-contrast focus indicator at all times.
Conclusion
Creating an accessible combobox in React requires careful synchronization of state, DOM references, and ARIA semantics. By implementing the aria-activedescendant pattern alongside comprehensive keyboard event handlers, you provide an inclusive, fluid experience for keyboard and screen reader users alike.
Feel free to extend this component with features like asynchronous remote data fetching, multi-selection tags, or custom rendering templates for complex option cards!