Building an Accessible Combobox in React: The Complete Guide to Autocomplete UI
Learn how to build a fully accessible combobox and autocomplete widget in React from scratch, complete with robust keyboard navigation, ARIA attributes, and screen reader support.
Building an Accessible Combobox in React: The Complete Guide to Autocomplete UI
Implementing a combobox—often referred to as an autocomplete or typeahead widget—is one of the most deceptively complex UI challenges in front-end development. What looks like a simple text input with a dropdown list requires careful orchestration of state, focus management, screen reader announcements, and intricate keyboard interactions.
When accessibility (a11y) is added to the mix, the complexity scales up. A poorly constructed combobox can trap screen reader users, fail keyboard traps, or completely ignore dynamic updates to filtered results.
In this comprehensive guide, we will build a robust, accessible combobox in React using TypeScript. We will dive deep into the WAI-ARIA 1.2 Combobox specification, contrast architectural patterns like aria-activedescendant versus roving tabindex, and implement seamless keyboard navigation.
Understanding the Anatomy of a Combobox
A WAI-ARIA combobox is a composite widget consisting of two primary parts:
- An input element (typically a text input) that controls the widget.
- A popup element (a listbox, grid, or tree) that displays suggestions based on the input value.
Key ARIA Attributes Required
role="combobox": Placed on the input (or its wrapper, depending on the pattern), signaling that the element controls a popup.aria-expanded: A boolean (trueorfalse) indicating whether the popup is currently visible.aria-haspopup: Set to"listbox"to inform assistive technologies that a list of choices will appear.aria-controls: Links the input directly to the ID of the popup listbox.aria-autocomplete: Typically set to"list"or"both"to indicate how the autocomplete behavior functions.
Architectural Choice: aria-activedescendant vs. Roving Tabindex
When implementing keyboard navigation within the popup listbox, you have two primary architectural patterns to choose from:
-
aria-activedescendant(Recommended for Comboboxes): Focus remains on the text input at all times. As the user presses the Arrow Down or Arrow Up keys, an ID reference is passed to the input’saria-activedescendantattribute. Screen readers announce the newly active option automatically without shifting browser focus away from the input. -
Roving Tabindex: Focus actually moves from the input element directly into the DOM nodes of the list items using
tabindex="-1"andtabindex="0".
For comboboxes, aria-activedescendant is vastly superior. Moving focus away from a text input while a user is typing breaks the writing flow and often causes erratic behavior across different screen reader and browser combinations.
Building the Component (TypeScript & React)
Let us construct a production-ready, accessible autocomplete component. We will manage state for the input value, open/closed status, and the currently highlighted item index.
1. Types and Interfaces
import React, { useState, useRef, useEffect, useId, KeyboardEvent } from 'react';
interface Option {
id: string;
label: string;
}
interface AccessibleComboboxProps {
options: Option[];
onSelect: (option: Option) => void;
placeholder?: string;
}
2. Component Implementation
export const AccessibleCombobox: React.FC<AccessibleComboboxProps> = ({
options,
onSelect,
placeholder = 'Search...',
}) => {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const comboboxId = useId();
const listboxId = `${comboboxId}-listbox`;
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// Filter options based on user input
const filteredOptions = options.filter((option) =>
option.label.toLowerCase().includes(query.toLowerCase())
);
// Close popup when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
inputRef.current &&
!inputRef.current.contains(event.target as Node) &&
listRef.current &&
!listRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
setIsOpen(true);
setActiveIndex(null);
};
const handleOptionClick = (option: Option) => {
setQuery(option.label);
onSelect(option);
setIsOpen(false);
setActiveIndex(null);
inputRef.current?.focus();
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setActiveIndex((prev) =>
prev === null || prev >= filteredOptions.length - 1 ? 0 : prev + 1
);
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setActiveIndex((prev) =>
prev === null || prev <= 0 ? filteredOptions.length - 1 : prev - 1
);
}
break;
case 'Enter':
e.preventDefault();
if (isOpen && activeIndex !== null && filteredOptions[activeIndex]) {
handleOptionClick(filteredOptions[activeIndex]);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
setActiveIndex(null);
break;
default:
break;
}
};
const activeOptionId =
isOpen && activeIndex !== null && filteredOptions[activeIndex]
? `${comboboxId}-option-${filteredOptions[activeIndex].id}`
: undefined;
return (
<div className="combobox-wrapper" style={{ position: 'relative', width: '300px' }}>
<label htmlFor={`${comboboxId}-input`} style={{ display: 'block', marginBottom: '4px' }}>
Choose an item
</label>
<div style={{ position: 'relative' }}>
<input
ref={inputRef}
id={`${comboboxId}-input`}
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeOptionId}
type="text"
value={query}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => setIsOpen(true)}
placeholder={placeholder}
style={{ width: '100%', padding: '8px', boxSizing: 'border-box' }}
/>
</div>
{isOpen && filteredOptions.length > 0 && (
<ul
ref={listRef}
id={listboxId}
role="listbox"
style={{
listStyle: 'none',
margin: 0,
padding: 0,
position: 'absolute',
top: '100%',
left: 0,
right: 0,
border: '1px solid #ccc',
backgroundColor: '#fff',
maxHeight: '200px',
overflowY: 'auto',
zIndex: 1000,
}}
>
{filteredOptions.map((option, index) => {
const optionId = `${comboboxId}-option-${option.id}`;
const isSelected = activeIndex === index;
return (
<li
key={option.id}
id={optionId}
role="option"
aria-selected={isSelected}
onClick={() => handleOptionClick(option)}
style={{
padding: '8px',
backgroundColor: isSelected ? '#007bff' : '#fff',
color: isSelected ? '#fff' : '#000',
cursor: 'pointer',
}}
>
{option.label}
</li>
);
})}
</ul>
)}
</div>
);
};
Keyboard Navigation Contract
To pass accessibility audits and provide a fluid user experience, your combobox must adhere to a strict keyboard interaction model:
| Key | Action |
|---|---|
Tab |
Moves focus into the combobox input. If the popup is open, pressing Tab should typically close the popup and move focus to the next focusable element. |
Arrow Down |
Opens the popup if closed. Moves visual highlight to the next item in the listbox. Loops back to the top if at the end. |
Arrow Up |
Opens the popup if closed. Moves visual highlight to the previous item. Loops to the bottom if at the start. |
Enter |
Selects the currently highlighted option, fills the input, and closes the popup. |
Escape |
Closes the popup immediately. Optionally clears the input value depending on your UX requirements. |
Pro-Tip: Never let
ArrowUporArrowDownmove the native text cursor position inside the<input />. Always calle.preventDefault()when intercepting these keys.
Enhancing Screen Reader Announcements
While aria-activedescendant informs screen readers of the currently highlighted option, dynamic changes to the result count can leave users in the dark.
For a truly bulletproof experience, consider adding a visually hidden live region (aria-live="polite") that announces search result changes:
<div className="sr-only" aria-live="polite" style={srOnlyStyles}>
{isOpen ? `${filteredOptions.length} results available.` : ''}
</div>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
Testing Your Combobox
Building accessibility features without testing with real assistive technologies is a recipe for regressions. Make sure to test your newly created React component against:
- VoiceOver (macOS/iOS): Verify that typing reads characters, arrow keys announce options dynamically, and pressing Enter triggers selection.
- NVDA or JAWS (Windows): Ensure
aria-controlssuccessfully links the input to the listbox and announcement latency is minimal. - Keyboard-Only Audit: Unplug your mouse entirely. Can you open, navigate, select, and close the combobox using solely
Tab,Arrowkeys,Enter, andEscape?
Conclusion
Implementing an accessible combobox requires careful synchronization of React state, precise ARIA attribute mapping, and rigorous keyboard event handling. By adopting the aria-activedescendant pattern, keeping focus pinned to the input, and honoring standard interaction models, you ensure that all users—regardless of ability or device—can seamlessly interact with your application’s autocomplete widgets.