Building an Accessible Combobox in React: ARIA Patterns & Keyboard Navigation
Learn how to build a fully accessible combobox and autocomplete component in React from scratch, mastering ARIA attributes, keyboard navigation, and screen reader announcements.
Building an Accessible Combobox in React: ARIA Patterns & Keyboard Navigation
When building a design system in React, few components are as deceptively complex as the Combobox. Often implemented simply as an input field with a dropdown list, a truly accessible combobox requires meticulous orchestration of focus management, state synchronization, screen reader announcements, and intricate keyboard navigation.
In this deep dive, we will walk through the architectural challenges of building a robust combobox from scratch. We will explore the WARIA Authoring Practices Guide (APG) standards, evaluate aria-activedescendant versus native focus management, and implement a production-ready component in React and TypeScript.
The Anatomy of an Accessible Combobox
A combobox is a composite widget. It combines a single-line text input with a popup (usually a listbox, grid, or tree) that helps the user set the value of the input. According to the WAI-ARIA 1.2 specification, a combobox must expose a distinct relationship between the input and the popup.
Let’s break down the essential semantic requirements:
- The Input Element: Acts as the control. It must possess
role="combobox". - The Popup Element: Contains the selectable options. It typically has
role="listbox". - The Option Elements: Individual items inside the popup with
role="option".
Core ARIA Attributes to Master
aria-expanded: A boolean indicating whether the popup is currently visible (true) or hidden (false).aria-haspopup: Set to"listbox"(or"dialog","tree","grid") to inform assistive technologies that the input controls a popup.aria-controls: Associates the input directly with the ID of the popup element.aria-autocomplete: Specifies the autocomplete behavior ("none","inline","list", or"both").aria-activedescendant: Identifies the DOM ID of the currently focused option within the listbox while the actual focus remains on the input.
Architectural Dilemma: Focus Management vs. aria-activedescendant
When building a combobox, you face a fundamental architectural choice regarding how keyboard focus moves through the list:
Approach A: Moving Native DOM Focus
In this approach, pressing the Down Arrow moves native focus (document.activeElement) from the input directly into the first item of the listbox.
- Pros: Simpler to implement if you rely solely on browser focus rings.
- Cons: It breaks the paradigm of typing into an input. Screen readers often get confused because the user’s cursor leaves the text field while they are still actively typing a query.
Approach B: Using aria-activedescendant (Recommended)
In this approach, native focus never leaves the input. Instead, you track an active index in state, and update aria-activedescendant="option-id-3" on the input. Screen readers announce the referenced option automatically as the user navigates.
- Pros: The user maintains uninterrupted typing focus in the input field. Screen readers seamlessly read the highlighted options.
- Cons: Requires explicit CSS styling to visually highlight the “active” descendant since native
:focusstyles will not apply.
For our implementation, we will use the aria-activedescendant pattern as specified by the WAI-ARIA APG.
Building the React Combobox Component
Let’s implement a typed, accessible autocomplete combobox in React using TypeScript. We will handle state management, ARIA wiring, and comprehensive keyboard event handlers.
1. Types and Interfaces
import React, { useState, useRef, useId, useMemo } from "";
export interface ComboboxOption {
id: string;
label: string;
value: string;
}
interface ComboboxProps {
options: ComboboxOption[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
label: string;
}
2. Component Implementation
export const Combobox: React.FC<ComboboxProps> = ({
options,
value,
onChange,
placeholder,
label,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState(value);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listboxId = useId();
const labelId = useId();
// Filter options based on user input
const filteredOptions = useMemo(() => {
if (!query) return options;
return options.filter((opt) =>
opt.label.toLowerCase().includes(query.toLowerCase())
);
}, [options, query]);
const activeOptionId =
activeIndex !== null && filteredOptions[activeIndex]
? `${listboxId}-option-${activeIndex}`
: undefined;
const handleOpen = () => {
setIsOpen(true);
};
const handleClose = () => {
setIsOpen(false);
setActiveIndex(null);
};
const handleSelect = (option: ComboboxOption) => {
setQuery(option.label);
onChange(option.value);
handleClose();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (!isOpen) {
handleOpen();
setActiveIndex(0);
} else {
setActiveIndex((prev) =>
prev === null || prev >= filteredOptions.length - 1 ? 0 : prev + 1
);
}
break;
case "ArrowUp":
e.preventDefault();
if (!isOpen) {
handleOpen();
setActiveIndex(filteredOptions.length - 1);
} else {
setActiveIndex((prev) =>
prev === null || prev <= 0 ? filteredOptions.length - 1 : prev - 1
);
}
break;
case "Enter":
e.preventDefault();
if (isOpen && activeIndex !== null && filteredOptions[activeIndex]) {
handleSelect(filteredOptions[activeIndex]);
}
break;
case "Escape":
e.preventDefault();
handleClose();
break;
default:
if (!isOpen) handleOpen();
break;
}
};
return (
<div className="combobox-wrapper" style={{ position: "relative", width: "300px" }}>
<label id={labelId} className="combobox-label" style={{ display: "block", marginBottom: "4px" }}>
{label}
</label>
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeOptionId}
aria-labelledby={labelId}
value={query}
placeholder={placeholder}
onChange={(e) => {
setQuery(e.target.value);
if (!isOpen) handleOpen();
setActiveIndex(null);
}}
onFocus={handleOpen}
onBlur={(e) => {
// Close listbox if focus moves outside the component wrapper
if (!e.currentTarget.parentElement?.contains(e.relatedTarget as Node)) {
handleClose();
}
}}
onKeyDown={handleKeyDown}
style={{ width: "100%", padding: "8px", boxSizing: "border-box" }}
/>
{isOpen && (
<ul
id={listboxId}
role="listbox"
aria-label={label}
style={{
position: "absolute",
top: "100%",
left: 0,
right: 0,
margin: 0,
padding: 0,
listStyle: "none",
background: "white",
border: "1px solid #ccc",
maxHeight: "200px",
overflowY: "auto",
zIndex: 1000,
}}
>
{filteredOptions.length === 0 ? (
<li role="option" aria-disabled="true" style={{ padding: "8px", color: "#888" }}>
No results found
</li>
) : (
filteredOptions.map((option, index) => {
const isSelected = option.value === value;
const isActive = index === activeIndex;
return (
<li
key={option.id}
id={`${listboxId}-option-${index}`}
role="option"
aria-selected={isSelected}
onMouseDown={(e) => {
// Prevent blur on input when clicking list item
e.preventDefault();
handleSelect(option);
}}
style={{
padding: "8px",
cursor: "pointer",
backgroundColor: isActive ? "#007bff" : "transparent",
color: isActive ? "white" : "black",
}}
>
{option.label}
</li>
);
})
)}
</ul>
)}
</div>
);
};
Bulletproof Keyboard Navigation Requirements
Creating a frictionless keyboard experience requires anticipating how users interact with select-like controls. Here is a checklist of interactions handled in our handleKeyDown logic:
Down Arrow/Up Arrow: Opens the listbox if closed. Cycles through options sequentially, wrapping around from the last item to the first (and vice versa).Enter: Selects the currently highlighted option viaaria-activedescendantand closes the popup.Escape: Closes the popup immediately. If the popup is already closed, standard browser behavior applies (or clears the input value).- Mouse Interoperability: Notice the use of
onMouseDowninstead ofonClickon the list items. When a user clicks a dropdown item, theblurevent fires on the input before theclickevent executes. UsingonMouseDownand callinge.preventDefault()stops the input from blurring prematurely.
Enhancing Screen Reader Feedback with Live Regions
For advanced autocomplete setups (such as asynchronous search results fetched from an API), screen reader users benefit greatly from knowing how many results were returned.
You can implement an aria-live region hidden visually to announce result counts:
<div className="sr-only" aria-live="polite" aria-atomic="true">
{isOpen ? `${filteredOptions.length} results available.` : ""}
</div>
Note: Ensure your .sr-only utility class clips content visually from the screen without hiding it from accessibility trees.
Conclusion
Building an accessible combobox in React goes far beyond rendering a conditional <ul> beneath an <input>. By strictly adhering to WAI-ARIA 1.2 specifications, managing aria-activedescendant states meticulously, and handling edge cases like mouse-down blur suppression, you ensure an inclusive experience for keyboard and screen reader users alike.
When scaling design systems, consider whether maintaining custom combobox code makes sense long-term, or if battle-tested primitives like Radix UI, React Aria, or Headless UI should be leveraged to handle these complex ARIA nuances out of the box.