Combos and Keys: Building an Accessible Combobox in React
Learn how to build a production-ready, fully accessible, and keyboard-navigable combobox component in React from scratch, covering ARIA roles, state machines, and aria-activedescendant.
Combos and Keys: Building an Accessible Combobox in React
Building a combobox—often referred to as an autocomplete or typeahead component—is one of the most deceptively complex tasks in frontend development. On the surface, it looks simple: an <input> paired with a dropdown list. Under the hood, however, it is a masterclass in state synchronization, focus management, and accessibility engineering.
In this deep dive, we will build a fully accessible, keyboard-navigable combobox component in React and TypeScript from scratch. We won’t rely on heavyweight UI libraries; instead, we’ll construct it using native web semantics and the WAI-ARIA 1.2 authoring practices.
The Architectural Challenge
A combobox is a hybrid widget. It combines two distinct interactive patterns:
- A text input (
role="combobox") where the user can type freely. - A popup listbox (
role="listbox") containing selectable options.
The core architectural tension lies in focus management. In a traditional dropdown, clicking an item moves physical DOM focus. But in a combobox, focus must remain on the text input at all times so the user can continue typing.
To communicate which option is currently highlighted to screen reader users without moving focus, we must rely on aria-activedescendant.
ARIA Roles and Attributes Breakdown
Before writing any React code, let’s establish the contract between our component and assistive technologies.
role="combobox": Applied to the text input. It informs screen readers that this input controls a popup.aria-expanded: A boolean (true/false) indicating whether the popup listbox is currently visible.aria-haspopup: Set to"listbox"to indicate the nature of the popup.aria-controls: Matches theidof the popup listbox element.aria-activedescendant: Contains theidof the currently focused option inside the listbox, even though focus remains on the input.role="listbox": Applied to the dropdown container.role="option": Applied to each item inside the listbox, complete witharia-selected.
Designing the State Machine
A robust combobox requires careful state management. We need to track:
isOpen: Whether the listbox is visible.inputValue: The current text inside the input.highlightedIndex: The index of the option currently focused via keyboard navigation.selectedItem: The currently committed value.
Let’s start by defining our TypeScript interfaces and the core component structure.
import React, { useState, useRef, useId, useEffect } from 'react';
interface ComboboxProps<T> {
items: T[];
itemToString: (item: T) => string;
onSelect: (item: T) => void;
placeholder?: string;
}
export function Combobox<T>({ items, itemToString, onSelect, placeholder }: ComboboxProps<T>) {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const comboboxId = useId();
const listboxId = `${comboboxId}-listbox`;
const inputRef = useRef<HTMLInputElement>(null);
// Filtering items based on input value
const filteredItems = items.filter(item =>
itemToString(item).toLowerCase().includes(inputValue.toLowerCase())
);
// ... state handlers will go here
}
Keyboard Navigation and Edge Cases
Keyboard interactions for a combobox are strict. Users expect specific behaviors for specific keys:
ArrowDown/ArrowUp: Open the listbox (if closed) and move the highlight index down or up.Enter: Select the currently highlighted option and close the listbox.Escape: Close the listbox. If already closed, clear the input.Home/End: Jump to the first or last item in the listbox.
Implementing this requires a robust onKeyDown handler on the input:
const handleKeyDown = (e: React.KeyboardEvent) => {
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]) {
selectItem(filteredItems[highlightedIndex]);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
setHighlightedIndex(-1);
break;
case 'Tab':
// Allow natural tab behavior, but ensure dropdown closes
setIsOpen(false);
break;
default:
break;
}
};
Handling aria-activedescendant
Because focus never leaves the input, screen readers need to know which option is active via aria-activedescendant. We generate a unique DOM ID for every option based on its index and map it to the input.
const getOptionId = (index: number) => `${comboboxId}-option-${index}`;
// On the input element:
aria-activedescendant={isOpen && highlightedIndex >= 0 ? getOptionId(highlightedIndex) : undefined}
Putting It All Together: The Complete Component
Here is the fully assembled Combobox component implementing robust accessibility patterns, pointer interactions, and clean layout handling.
import React, { useState, useRef, useId, useEffect } from 'react';
import './Combobox.css';
interface ComboboxProps<T> {
items: T[];
itemToString: (item: T) => string;
onSelect: (item: T) => void;
placeholder?: string;
}
export function Combobox<T>({ items, itemToString, onSelect, placeholder }: ComboboxProps<T>) {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const comboboxId = useId();
const listboxId = `${comboboxId}-listbox`;
const inputRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const filteredItems = items.filter(item =>
itemToString(item).toLowerCase().includes(inputValue.toLowerCase())
);
const selectItem = (item: T) => {
setInputValue(itemToString(item));
onSelect(item);
setIsOpen(false);
setHighlightedIndex(-1);
};
// Reset highlight when filtered items change
useEffect(() => {
setHighlightedIndex(-1);
}, [inputValue]);
const getOptionId = (index: number) => `${comboboxId}-option-${index}`;
return (
<div className="combobox-wrapper">
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
className="combobox-input-container"
>
<input
ref={inputRef}
id={comboboxId}
type="text"
autoComplete="off"
value={inputValue}
placeholder={placeholder}
aria-autocomplete="list"
aria-controls={listboxId}
aria-activedescendant={isOpen && highlightedIndex >= 0 ? getOptionId(highlightedIndex) : undefined}
onChange={(e) => {
setInputValue(e.target.value);
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={/* handler defined above */}
/>
</div>
{isOpen && (
<ul
id={listboxId}
ref={listboxRef}
role="listbox"
className="combobox-listbox"
>
{filteredItems.length === 0 ? (
<li className="combobox-no-results" role="presentation">
No results found
</li>
) : (
filteredItems.globallyFiltered?.length ?? filteredItems.map((item, index) => {
const isHighlighted = highlightedIndex === index;
return (
<li
key={getOptionId(index)}
id={getOptionId(index)}
role="option"
aria-selected={isHighlighted}
className={`combobox-option ${isHighlighted ? 'highlighted' : ''}`}
onMouseEnter={() => setHighlightedIndex(index)}
onClick={() => selectItem(item)}
>
{itemToString(item)}
</li>
);
})
)}
</ul>
)}
</div>
);
}
Handling Click-Outside and Blur Events
One final edge case that trips up developers is closing the dropdown when the user clicks outside or tabs away. Because focus remains inside the input during list interaction, a simple onBlur event on the input will fire prematurely when a user clicks an option in the listbox.
To solve this, use a relatedTarget check or a pointerdown listener wrapper:
useEffect(() => {
const handleOutsideClick = (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', handleOutsideClick);
return () => document.removeEventListener('mousedown', handleOutsideClick);
}, []);
Conclusion
Building an accessible combobox requires treating accessibility not as an afterthought, but as the foundational architecture of your UI state machine. By meticulously handling aria-activedescendant, maintaining focus on the text input, and supporting comprehensive keyboard workflows, you ensure that screen reader and keyboard-only users enjoy a first-class experience.
When writing complex design systems in React, investing time in these primitives pays off tenfold across your entire application ecosystem.