Building an Accessible Combobox in React: ARIA Patterns, Keyboard Navigation, and Live Regions
A practical, production-grade guide to building a fully accessible autocomplete combobox in React from scratch, featuring robust keyboard navigation, ARIA attributes, and live regions.
Building an Accessible Combobox in React: ARIA Patterns, Keyboard Navigation, and Live Regions
The combobox (or autocomplete) is one of the most deceptively complex UI components in web development. At a glance, it’s just an input field paired with a dropdown list. Under the hood, however, it requires a delicate choreography of state management, keyboard event handling, focus management, and screen reader announcements.
While third-party libraries like Downshift or Radix UI provide robust primitives, building a combobox from scratch is an invaluable exercise. It forces you to deeply understand the WAI-ARIA Authoring Practices Guide (APG) and guarantees that your users—regardless of whether they use a mouse, screen reader, or keyboard—have a first-class experience.
In this tutorial, we will build a production-ready, fully accessible Combobox component in React using TypeScript.
Understanding the ARIA Combobox Pattern
Before writing code, we must understand how assistive technologies perceive a combobox. According to the WAI-ARIA 1.2 specification, a combobox is an input widget that controls another element, typically a listbox or grid, that can dynamically pop up to help the user set the value of the input.
Key architectural requirements include:
- The Input (
role="combobox"): The text field that receives user input. - State Attributes:
aria-expanded: Indicates whether the popup list is currently visible (trueorfalse).aria-controls: Points to the ID of the popup list element.aria-autocomplete: Specifies the autocomplete behavior (list,inline, orboth).aria-activedescendant: Points to the ID of the currently highlighted option within the list, keeping focus on the input while allowing screen readers to announce active options.
- The Listbox (
role="listbox"): The container holding the selectable options (role="option").
Designing the Component State
Let’s start by defining our TypeScript types and setting up the core state variables. We need to track the input value, whether the list is open, the filtered items, and the index of the currently highlighted option.
import React, { useState, useRef, useEffect, useId, KeyboardEvent } from 'react';
interface ComboboxProps {
items: string[];
onSelect?: (item: string) => void;
placeholder?: string;
}
export const Combobox: React.FC<ComboboxProps> = ({
items,
onSelect,
placeholder = 'Search...',
}) => {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const inputRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const id = useId();
const listboxId = `${id}-listbox`;
const inputId = `${id}-input`;
// Filter items based on user input
const filteredItems = items.filter((item) =>
item.toLowerCase().includes(query.toLowerCase())
);
// ... component logic continues
};
Implementing Robust Keyboard Navigation
Keyboard accessibility is where most custom comboboxes fail. Users expect specific behaviors when interacting with a combobox via keyboard:
- ArrowDown / ArrowUp: Opens the listbox (if closed) and navigates through options. Moving past the last item should loop or stop safely.
- Enter: Selects the currently highlighted option and closes the listbox.
- Escape: Closes the listbox and clears the active selection.
- Home / End: Jumps to the first or last item in the list.
Here is how we implement this inside a onKeyDown handler on the input element:
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(0);
} else {
setActiveIndex((prev) =>
prev < filteredItems.length - 1 ? prev + 1 : 0
);
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(filteredItems.length - 1);
} else {
setActiveIndex((prev) =>
prev > 0 ? prev - 1 : filteredItems.length - 1
);
}
break;
case 'Enter':
e.preventDefault();
if (isOpen && activeIndex >= 0 && filteredItems[activeIndex]) {
selectItem(filteredItems[activeIndex]);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
setActiveIndex(-1);
break;
case 'Home':
if (isOpen && filteredItems.length > 0) {
e.preventDefault();
setActiveIndex(0);
}
break;
case 'End':
if (isOpen && filteredItems.length > 0) {
e.preventDefault();
setActiveIndex(filteredItems.length - 1);
}
break;
default:
if (!isOpen) setIsOpen(true);
setActiveIndex(-1);
break;
}
};
const selectItem = (item: string) => {
setQuery(item);
setIsOpen(false);
setActiveIndex(-1);
onSelect?.(item);
inputRef.current?.focus();
};
Managing Focus and aria-activedescendant
Instead of moving physical DOM focus from the input into the dropdown list (which causes screen readers to lose context of the input field), modern ARIA patterns rely on aria-activedescendant.
By keeping physical focus on the <input> and updating aria-activedescendant with the DOM ID of the currently focused option, screen readers will announce the highlighted item instantly as the user presses the arrow keys.
// Calculate active descendant ID safely
const activeDescendantId =
isOpen && activeIndex >= 0 && activeIndex < filteredItems.length
? `${id}-option-${activeIndex}`
: undefined;
Let’s render the input element applying these critical ARIA attributes:
<div className="combobox-wrapper">
<label htmlFor={inputId} className="combobox-label">
Select an option
</label>
<input
ref={inputRef}
id={inputId}
role="combobox"
type="text"
aria-expanded={isOpen}
aria-autocomplete="list"
aria-controls={listboxId}
aria-activedescendant={activeDescendantId}
value={query}
onChange={(e) => {
setQuery(e.target.value);
if (!isOpen) setIsOpen(true);
setActiveIndex(-1);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
/>
{/* Listbox rendered below */}
</div>
Providing Feedback with ARIA Live Regions
When a user types into an autocomplete field, sighted users immediately see the dropdown results change. Screen reader users, however, need to know how many results are available without being overwhelmed by chatter.
We can implement an assertive or polite live region that announces result counts dynamically:
<div
id={`${id}-status`}
className="sr-only"
aria-live="polite"
aria-atomic="true"
>
{isOpen && `${filteredItems.length} results available.`}
</div>
Tip: The class
sr-onlyshould hide the element visually while keeping it fully accessible to screen reader parsers.
.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;
}
Assembling the Complete Component
Let’s put everything together into our complete React component, including mouse click handling and closing the dropdown when clicking outside.
import React, { useState, useRef, useEffect, useId, KeyboardEvent } from 'react';
import './Combobox.css';
interface ComboboxProps {
items: string[];
onSelect?: (item: string) => void;
placeholder?: string;
}
export const Combobox: React.FC<ComboboxProps> = ({
items,
onSelect,
placeholder = 'Search...',
}) => {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const id = useId();
const listboxId = `${id}-listbox`;
const inputId = `${id}-input`;
const filteredItems = items.filter((item) =>
item.toLowerCase().includes(query.toLowerCase())
);
// Close dropdown on outside click
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);
}, []);
const selectItem = (item: string) => {
setQuery(item);
setIsOpen(false);
setActiveIndex(-1);
onSelect?.(item);
inputRef.current?.focus();
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(0);
} else {
setActiveIndex((prev) => (prev < filteredItems.length - 1 ? prev + 1 : 0));
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(filteredItems.length - 1);
} else {
setActiveIndex((prev) => (prev > 0 ? prev - 1 : filteredItems.length - 1));
}
break;
case 'Enter':
e.preventDefault();
if (isOpen && activeIndex >= 0 && filteredItems[activeIndex]) {
selectItem(filteredItems[activeIndex]);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
setActiveIndex(-1);
break;
case 'Home':
if (isOpen && filteredItems.length > 0) {
e.preventDefault();
setActiveIndex(0);
}
break;
case 'End':
if (isOpen && filteredItems.length > 0) {
e.preventDefault();
setActiveIndex(filteredItems.length - 1);
}
break;
default:
if (!isOpen) setIsOpen(true);
setActiveIndex(-1);
break;
}
};
const activeDescendantId =
isOpen && activeIndex >= 0 && activeIndex < filteredItems.length
? `${id}-option-${activeIndex}`
: undefined;
return (
<div className="combobox-container" ref={containerRef}>
<label htmlFor={inputId} className="combobox-label">
Search Countries
</label>
<div className="combobox-input-wrapper">
<input
ref={inputRef}
id={inputId}
role="combobox"
type="text"
aria-expanded={isOpen}
aria-autocomplete="list"
aria-controls={listboxId}
aria-activedescendant={activeDescendantId}
value={query}
onChange={(e) => {
setQuery(e.target.value);
if (!isOpen) setIsOpen(true);
setActiveIndex(-1);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
/>
</div>
{/* Live Region for Screen Readers */}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{isOpen ? `${filteredItems.length} suggestions available.` : ''}
</div>
{isOpen && (
<ul
id={listboxId}
role="listbox"
className="combobox-listbox"
>
{filteredItems.length === 0 ? (
<li className="combobox-option-empty" role="presentation">
No results found
</li>
) : (
filteredItems.map((item, index) => {
const isHighlighted = index === activeIndex;
return (
<li
key={item}
id={`${id}-option-${index}`}
role="option"
aria-selected={isHighlighted}
className={`combobox-option ${
isHighlighted ? 'combobox-option-active' : ''
}`}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => selectItem(item)}
>
{item}
</li>
);
})
)}
</ul>
)}
</div>
);
};
Conclusion
Building an accessible combobox requires careful attention to ARIA specifications, keyboard event orchestration, and screen reader announcements. By implementing role="combobox", maintaining focus on the input while tracking options via aria-activedescendant, and supplying polite live regions for status updates, you ensure that all users enjoy a seamless experience.
Take this component, add your own styling, hook up asynchronous data fetching (with debounce and loading announcements), and you will have a world-class, accessible autocomplete ready for production.