Comboboxes Without Tears: Building an Accessible Autocomplete in React
A deep dive into managing complex keyboard navigation, listbox aria attributes, active descendant tracking, and screen reader announcements for a custom combobox using React and TypeScript.
Comboboxes Without Tears: Building an Accessible Autocomplete in React
If you have ever attempted to build a custom autocomplete or combobox component from scratch, you already know the sinking feeling that hits halfway through development. What starts as a simple input field paired with a dropdown list quickly spirals into a complex state machine of focus management, keyboard event handlers, scrolling behaviors, and screen reader announcements.
Native HTML elements like <select> are fully accessible out of the box, but their styling capabilities are notoriously restrictive. When product designers hand over a sleek, custom autocomplete design, we are forced to build a custom component. Doing this accessibly requires adherence to the WAI-ARIA Combobox Pattern.
In this deep dive, we will construct a production-ready, fully keyboard-navigable, and screen-reader-friendly Combobox component in React and TypeScript.
Understanding the ARIA Combobox Pattern
The WAI-ARIA specification defines a combobox as 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 our component accessible to assistive technologies, we must coordinate several core ARIA attributes:
- The Input: Acts as the controller. It requires
role="combobox",aria-expanded,aria-autocomplete="list",aria-controls(pointing to the listbox ID), andaria-activedescendant(pointing to the ID of the currently highlighted option). - The Listbox: The popup container. It requires
role="listbox"and a uniqueid. - The Options: Individual items within the listbox. Each requires
role="option", a uniqueid, and anaria-selectedstate.
Getting these relationships right is non-negotiable. If screen readers cannot map the input to the listbox, or the highlighted option to the input, visually impaired users will be flying blind.
Designing the State Architecture
Before writing code, let’s map out the state we need to manage:
isOpen: Controls whether the dropdown listbox is visible.query: The current text value inside the input.activeIndex: The index of the currently highlighted option in the listbox (used foraria-activedescendant).selectedItem: The committed selection.
Here is our TypeScript interface setup for the component props and items:
export interface ComboboxItem {
id: string;
label: string;
value: string;
}
export interface ComboboxProps {
items: ComboboxItem[];
onSelect: (item: ComboboxItem) => void;
placeholder?: string;
label: string;
}
Building the Component: Step-by-Step
Let’s assemble the core React component. We will use useId for generating robust, collision-free DOM IDs required by ARIA attributes.
import React, { useState, useRef, useId, useEffect } from 'react';
export function Combobox({ items, onSelect, placeholder, label }: ComboboxProps) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
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 inputId = `combobox-input-${uniqueId}`;
// Filter items based on user query
const filteredItems = items.filter((item) =>
item.label.toLowerCase().includes(query.toLowerCase())
);
// ... keyboard and interaction handlers go here
return (
<div className="combobox-wrapper">
<label htmlFor={inputId} className="combobox-label">
{label}
</label>
<div className="combobox-input-container">
<input
ref={inputRef}
id={inputId}
type="text"
role="combobox"
aria-expanded={isOpen}
aria-autocomplete="list"
aria-controls={listboxId}
aria-activedescendant={
activeIndex >= 0 ? `option-${uniqueId}-${activeIndex}` : undefined
}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
setActiveIndex(0);
}}
onFocus={() => setIsOpen(true)}
placeholder={placeholder}
/>
</div>
{isOpen && filteredItems.length > 0 && (
<ul
ref={listboxRef}
id={listboxId}
role="listbox"
className="combobox-listbox"
>
{filteredItems.map((item, index) => {
const optionId = `option-${uniqueId}-${index}`;
const isSelected = index === activeIndex;
return (
<li
key={item.id}
id={optionId}
role="option"
aria-selected={isSelected}
className={`combobox-option ${isSelected ? 'active' : ''}`}
onMouseDown={(e) => {
// Prevent blur on input when clicking option
e.preventDefault();
handleSelect(item);
}}
>
{item.label}
</li>
);
})}
</ul>
)}
</div>
);
}
Mastering Keyboard Navigation
Keyboard interactions are where most custom comboboxes fall apart. Users expect specific behaviors:
ArrowDown: Opens the listbox if closed, or moves focus down to the next option.ArrowUp: Opens the listbox if closed, or moves focus up to the previous option.Enter: Commits the currently active option.Escape: Closes the listbox.
Let’s implement the onKeyDown handler for our input element:
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setActiveIndex((prev) =>
prev < filteredItems.length - 1 ? prev + 1 : 0
);
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setActiveIndex((prev) =>
prev > 0 ? prev - 1 : filteredItems.length - 1
);
}
break;
case 'Enter':
e.preventDefault();
if (isOpen && activeIndex >= 0 && filteredItems[activeIndex]) {
handleSelect(filteredItems[activeIndex]);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
setActiveIndex(-1);
break;
default:
break;
}
};
The Magic of aria-activedescendant
Notice that we are not moving native DOM focus (document.activeElement) to the list items. Native focus remains firmly planted on the <input> element.
Instead, we use aria-activedescendant. By passing the ID of the currently highlighted <li> element to the input’s aria-activedescendant attribute, screen readers automatically announce the highlighted option as if the user were navigating a native listbox, while the user continues typing freely in the input.
Handling Scroll Synchronization and Focus Management
When navigating long lists with arrow keys, the active item can easily scroll out of the visible viewport. We need to programmatically scroll the active item into view:
useEffect(() => {
if (!isOpen || activeIndex < 0 || !listboxRef.current) return;
const listElement = listboxRef.current;
const optionElement = listElement.children[activeIndex] as HTMLElement;
if (optionElement) {
const optionTop = optionElement.offsetTop;
const optionBottom = optionTop + optionElement.offsetHeight;
const viewTop = listElement.scrollTop;
const viewBottom = viewTop + listElement.clientHeight;
if (optionTop < viewTop) {
listElement.scrollTop = optionTop;
} else if (optionBottom > viewBottom) {
listElement.scrollTop = optionBottom - listElement.clientHeight;
}
}
}, [activeIndex, isOpen]);
Handling Blur Events Safely
A common bug in custom comboboxes is the “race condition” between clicking an option and the input losing focus. When a user clicks a dropdown item, the input fires a onBlur event before the onClick event registers, causing the dropdown to close prematurely and ignoring the selection.
We solve this by using onMouseDown on the options and calling e.preventDefault(), which prevents the input from losing focus in the first place:
const handleSelect = (item: ComboboxItem) => {
onSelect(item);
setQuery(item.label);
setIsOpen(false);
setActiveIndex(-1);
inputRef.current?.blur();
};
To handle clicking completely outside the component, we can add a simple document-level click listener:
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);
}, []);
Conclusion
Building an accessible autocomplete widget in React requires careful attention to ARIA patterns, keyboard event handling, and focus states. By leveraging aria-activedescendant, we keep typing interactions smooth while delivering a fully native screen-reader experience.
Here is a quick checklist for your next custom combobox:
- Input has
role="combobox",aria-expanded, andaria-controls. - Listbox has
role="listbox"and matches the ID inaria-controls. - Options have
role="option"andaria-selected. - Active option ID is passed to
aria-activedescendanton the input. - Arrow keys navigate options without moving native focus away from the input.
- Escape closes the dropdown; Enter selects the active option.
- Click interactions prevent input blur using
onMouseDownprevention.
Mastering these patterns ensures your applications are robust, performant, and inclusive for every user.