Beyond the Basics: Building a Fully Accessible Combobox and Autocomplete in React
A deep dive into building an accessible React combobox and autocomplete from scratch using robust keyboard navigation, ARIA attributes, and screen reader announcements.
Beyond the Basics: Building a Fully Accessible Combobox and Autocomplete in React
Autocomplete and combobox components are staples of modern web applications. Whether you are building a country selector, a global search bar, or an ‘@’ mention input, the user experience hinges on responsiveness and predictability. However, building these components in React often leads to accessibility regressions.
Screen readers get confused, keyboard traps catch users off guard, and focus management breaks down when filtering lists dynamically.
In this guide, we will build a production-ready, fully accessible Combobox component in React using TypeScript. We won’t rely on heavy UI libraries; instead, we will leverage native HTML, the WAI-ARIA 1.2 Combobox pattern, and precise event handling.
Understanding the ARIA 1.2 Combobox Pattern
The WAI-ARIA specification for comboboxes has historically been notoriously difficult to implement. Fortunately, ARIA 1.2 simplified the mental model significantly.
At its core, a combobox is an input pattern that controls a popup (usually a listbox) to help the user set the value of the input.
The semantic relationship relies on three primary elements:
- The Combobox Input (
role="combobox"): The text field receiving user input. - The Popup (
role="listbox"): The container holding the selectable options. - The Options (
role="option"): The individual items inside the listbox.
Key ARIA Attributes to Remember
aria-expanded: Tells assistive technologies whether the popup list is currently visible (trueorfalse).aria-haspopup: Set to"listbox"on the input element.aria-controls: Links the input directly to the DOMidof the listbox.aria-autocomplete: Indicates how the suggestion behavior works ("list","inline", or"both").aria-activedescendant: Identifies the currently focused option within the listbox without moving the physical DOM focus away from the input.
Focus Management: aria-activedescendant vs. Roving Tabindex
When building list navigation, you have two primary strategies for managing keyboard focus:
- Roving Tabindex: Shifting
tabindex="0"dynamically to the currently focused DOM node while setting others totabindex="-1". aria-activedescendant: Keeping the DOM focus permanently on the<input>element while using an ID reference to signal to screen readers which option is visually highlighted.
Why Choose aria-activedescendant for Comboboxes?
For an autocomplete combobox, keeping focus on the input is superior. If focus moves to the listbox items, the user loses their cursor position in the text input, breaking typing flow.
By using aria-activedescendant, the user can type, use the arrow keys to highlight suggestions, and read the updated selection instantly—all while the cursor remains firmly inside the input.
Building the Component: Step-by-Step
Let’s construct our TypeScript-powered component. We will create a flexible Autocomplete component that accepts a list of items and an on-select callback.
1. Types and Interfaces
import React, { useState, useRef, useId, KeyboardEvent, ChangeEvent } from 'react';
export interface Option {
id: string;
label: string;
}
interface AutocompleteProps {
options: Option[];
value: string;
onChange: (value: string) => void;
onSelect: (option: Option) => void;
placeholder?: string;
}
2. Component Structure and State
We need state for tracking the open/closed status of the dropdown, the highlighted index for keyboard navigation, and a live-region announcer for screen reader feedback.
export const Autocomplete: React.FC<AutocompleteProps> = ({
options,
value,
onChange,
onSelect,
placeholder = 'Search...',
}) => {
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const [announcement, setAnnouncement] = useState<string>('');
const inputRef = useRef<HTMLInputElement>(null);
const listboxId = useId();
// Filter options based on input value
const filteredOptions = options.filter(option =>
option.label.toLowerCase().includes(value.toLowerCase())
);
// ... handlers go here
return (
<div className="relative w-full max-w-sm">
{/* Screen Reader Live Region */}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{announcement}
</div>
{/* Component markup continues below */}
</div>
);
};
3. Implementing Keyboard Navigation
Handling keyboard events is where accessibility is won or lost. We need to support:
ArrowDown/ArrowUp: Cycle through suggestions.Enter: Select the currently highlighted option.Escape: Close the listbox.
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
}
setHighlightedIndex(prev => {
const nextIndex = prev < filteredOptions.length - 1 ? prev + 1 : 0;
announceSelection(filteredOptions[nextIndex]);
return nextIndex;
});
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
}
setHighlightedIndex(prev => {
const nextIndex = prev > 0 ? prev - 1 : filteredOptions.length - 1;
announceSelection(filteredOptions[nextIndex]);
return nextIndex;
});
break;
case 'Enter':
e.preventDefault();
if (isOpen && highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
selectOption(filteredOptions[highlightedIndex]);
}
break;
case 'Escape':
setIsOpen(false);
setHighlightedIndex(-1);
break;
default:
break;
}
};
const announceSelection = (option: Option) => {
if (option) {
setAnnouncement(`${option.label}, suggestion ${highlightedIndex + 1} of ${filteredOptions.length}`);
}
};
const selectOption = (option: Option) => {
onSelect(option);
onChange(option.label);
setIsOpen(false);
setHighlightedIndex(-1);
inputRef.current?.focus();
};
4. Assembling the JSX and ARIA Bindings
Now, let’s connect our state and handlers to the DOM elements using proper ARIA attributes.
return (
<div className="relative w-full max-w-sm">
<div className="sr-only" aria-live="polite">
{announcement}
</div>
<div className="flex flex-col">
<label htmlFor={`${listboxId}-input`} className="text-sm font-medium mb-1">
Select an option
</label>
<input
ref={inputRef}
id={`${listboxId}-input`}
type="text"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={
highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined
}
value={value}
onChange={e => {
onChange(e.target.value);
setIsOpen(true);
setHighlightedIndex(-1);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{isOpen && filteredOptions.length > 0 && (
<ul
id={listboxId}
role="listbox"
className="absolute z-10 w-full mt-1 bg-white border rounded-md shadow-lg max-h-60 overflow-auto"
>
{filteredOptions.map((option, index) => {
const isHighlighted = index === highlightedIndex;
return (
<li
key={option.id}
id={`${listboxId}-option-${index}`}
role="option"
aria-selected={isHighlighted}
onClick={() => selectOption(option)}
onMouseEnter={() => setHighlightedIndex(index)}
className={`px-3 py-2 cursor-pointer ${
isHighlighted ? 'bg-blue-100 text-blue-900' : 'text-gray-900'
}`}
>
{option.label}
</li>
);
})}
</ul>
)}
</div>
);
};
Handling Edge Cases and Polish
An accessible component must handle edge cases gracefully. Here are three critical enhancements to keep in mind:
1. Click-Outside to Close
If a user clicks anywhere outside the input or dropdown, the listbox should collapse.
import { useEffect } from 'react';
// Inside your component:
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);
}, []);
2. Screen Reader Live Announcements
While aria-activedescendant informs screen readers of focus shifts in modern browsers, inconsistent screen reader support across platforms (like NVDA or VoiceOver combined with various browsers) makes a visually hidden aria-live region a bulletproof fallback for announcing option counts and changes.
3. Mouse and Keyboard Interoperability
Notice how our implementation handles onMouseEnter alongside keyboard navigation. When a user hovers over an option with the mouse, highlightedIndex updates so that if they switch immediately to keyboard navigation (e.g., pressing ArrowDown), the selection picks up right where the mouse left off.
Conclusion
Building an accessible combobox from scratch requires careful orchestration of focus states, ARIA attributes, and keyboard listeners. By utilizing the WAI-ARIA 1.2 pattern, keeping focus on the input with aria-activedescendant, and carefully managing state transitions, you can deliver a buttery-smooth autocomplete experience that works seamlessly for everyone—whether they use a mouse, a keyboard, or a screen reader.
Checklist for Your Next Custom Combobox:
- Input has
role="combobox",aria-expanded,aria-haspopup, andaria-controls. - Listbox has
role="listbox"and a matching uniqueid. - Options have
role="option"andaria-selected. -
aria-activedescendantpoints to the active option ID while focus stays on the input. - Comprehensive keyboard support (
ArrowDown,ArrowUp,Enter,Escape). - Click-outside listener to close the dropdown securely.