Unruly Dropdowns: Crafting an Accessible Combobox in React and TypeScript
A deep dive into WAI-ARIA combobox patterns, composite widget keyboard navigation, and async screen reader integration in React and TypeScript.
Unruly Dropdowns: Crafting an Accessible Combobox in React and TypeScript
Dropdown menus, autocomplete inputs, and typeahead selectors are staples of modern web applications. Yet, if you audit a random selection of popular component libraries, you will find that a shocking number of comboboxes fail basic accessibility checks.
Building a combobox that satisfies the WAI-ARIA Authoring Practices Guide (APG) is notoriously difficult. It requires orchestrating focus states, managing a complex keyboard contract, synchronizing popup visibility, and ensuring async loading states are appropriately communicated to assistive technologies.
In this deep dive, we will construct a robust, production-ready, accessible combobox component from scratch using React, TypeScript, and modern hooks.
Understanding the WAI-ARIA Combobox Pattern
A combobox is a composite widget. It combines a single-line text input with a popup (usually a listbox) that helps the user set the value of the input.
According to the WAI-ARIA specification, a proper combobox must adhere to a strict structural and attribute contract:
- The Input Element: Acts as the trigger and search input. It must have
role="combobox". - The Popup Element: Contains the options. It must have
role="listbox". - The Option Elements: Individual choices within the list. They must have
role="option".
Furthermore, dynamic attributes link these elements together:
aria-expanded: Tells screen readers whether the popup is open (true) or closed (false).aria-controls: Points to the ID of the popup element.aria-activedescendant: Points to the ID of the currently focused option in the listbox, allowing the input to maintain physical focus while the screen reader “focuses” individual options.
TypeScript Interfaces and State Architecture
Let’s begin by defining our TypeScript types. We need a flexible item generic so our combobox can accept simple strings or complex objects.
import React, { useState, useRef, useEffect, useId, KeyboardEvent, useTransition } from 'react';
export interface ComboboxItem {
id: string;
label: string;
value: string;
disabled?: boolean;
}
export interface ComboboxProps<T extends ComboboxItem> {
items: T[];
value: T | null;
onChange: (item: T | null) => void;
onSearchChange: (query: string) => void;
isLoading?: boolean;
placeholder?: string;
label: string;
}
Managing State
Our component needs to track several distinct pieces of state:
query: The current text inside the input.isOpen: Whether the listbox popup is visible.activeIndex: The index of the currently highlighted option (used foraria-activedescendant).
export function Combobox<T extends ComboboxItem>({
items,
value,
onChange,
onSearchChange,
isLoading = false,
placeholder = 'Search...',
label,
}: ComboboxProps<T>) {
const [query, setQuery] = useState(value ? value.label : '');
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [isPending, startTransition] = useTransition();
const comboboxId = useId();
const listboxId = `${comboboxId}-listbox`;
const inputId = `${comboboxId}-input`;
const labelId = `${comboboxId}-label`;
const inputRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
// ... component logic continues
}
The Keyboard Navigation Contract
A truly accessible combobox must be fully operable via keyboard alone. Sighted keyboard users and blind screen reader users rely on specific keys to navigate composite widgets.
Here is our keyboard interaction model:
ArrowDown/ArrowUp: Opens the listbox (if closed) and moves the active highlight down or up through the options.Enter: Selects the currently highlighted option and closes the listbox.Escape: Closes the listbox. If the listbox is already closed, clears the input.Home/End: Moves the highlight to the first or last option.
Let’s implement the onKeyDown handler for our input:
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setActiveIndex((prev) => {
if (prev === null || prev >= items.length - 1) return 0;
return prev + 1;
});
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setActiveIndex((prev) => {
if (prev === null || prev <= 0) return items.length - 1;
return prev - 1;
});
}
break;
case 'Enter':
e.preventDefault();
if (isOpen && activeIndex !== null && items[activeIndex]) {
selectItem(items[activeIndex]);
}
break;
case 'Escape':
e.preventDefault();
if (isOpen) {
setIsOpen(false);
setActiveIndex(null);
} else {
setQuery('');
onChange(null);
}
break;
case 'Home':
if (isOpen && items.length > 0) {
e.preventDefault();
setActiveIndex(0);
}
break;
case 'End':
if (isOpen && items.length > 0) {
e.preventDefault();
setActiveIndex(items.length - 1);
}
break;
default:
break;
}
};
Managing Focus and aria-activedescendant
In a standard dropdown (<select>), the browser moves focus directly to <option> elements. However, in a combobox, focus remains on the text input at all times.
To inform screen readers which option is currently selected or highlighted without moving physical focus, we use aria-activedescendant. This attribute takes the DOM id of the currently active option.
// Determine active descendant ID string
const activeDescendantId =
isOpen && activeIndex !== null && items[activeIndex]
? `${comboboxId}-option-${activeIndex}`
: undefined;
When activeIndex changes, we also want to ensure the highlighted option is scrolled into view within the popup container:
useEffect(() => {
if (activeIndex !== null && listboxRef.current) {
const activeNode = listboxRef.current.children[activeIndex] as HTMLElement;
if (activeNode) {
activeNode.scrollIntoView({ block: 'nearest' });
}
}
}, [activeIndex]);
Handling Async Loading States & Screen Reader Announcements
When users type into an autocomplete input, data is frequently fetched asynchronously from an API. Screen reader users need to know when a search is in progress and when results have updated, without being flooded by redundant noise.
We can achieve this using two complementary techniques:
aria-busy: Attached to the listbox while data is fetching.- Live Regions (
aria-live): An off-screen status region that announces result counts.
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newQuery = e.target.value;
setQuery(newQuery);
setIsOpen(true);
setActiveIndex(null);
// Wrap parent search callback in transition to keep typing snappy
startTransition(() => {
onSearchChange(newQuery);
});
};
const selectItem = (item: T) => {
onChange(item);
setQuery(item.label);
setIsOpen(false);
setActiveIndex(null);
inputRef.current?.blur();
};
Component Render Structure
Putting all the pieces together, our JSX structure ensures complete WAI-ARIA compliance:
return (
<div className="combobox-wrapper">
<label id={labelId} htmlFor={inputId} className="combobox-label">
{label}
</label>
<div className="combobox-input-container">
<input
ref={inputRef}
id={inputId}
role="combobox"
aria-expanded={isOpen}
aria-autocomplete="list"
aria-controls={listboxId}
aria-activedescendant={activeDescendantId}
aria-labelledby={labelId}
value={query}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => setIsOpen(true)}
placeholder={placeholder}
/>
{isLoading && <span className="spinner" aria-hidden="true" />}
</div>
{isOpen && (
<ul
ref={listboxRef}
id={listboxId}
role="listbox"
aria-label={label}
aria-busy={isLoading}
className="combobox-listbox"
>
{isLoading && items.length === 0 ? (
<li className="combobox-status" role="status">
Loading results...
</li>
) : items.length === 0 ? (
<li className="combobox-status" role="status">
No results found
</li>
) : (
items.map((item, index) => {
const isSelected = value?.id === item.id;
const isActive = activeIndex === index;
return (
<li
key={item.id}
id={`${comboboxId}-option-${index}`}
role="option"
aria-selected={isSelected}
className={`combobox-option ${
isActive ? 'active' : ''
} ${isSelected ? 'selected' : ''}`}
onMouseDown={(e) => {
// Prevent blur on input when clicking option
e.preventDefault();
selectItem(item);
}}
onMouseEnter={() => setActiveIndex(index)}
>
{item.label}
</li>
);
})
)}
</ul>
)}
{/* Visually hidden live region for screen reader result announcements */}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{!isLoading && `${items.length} results available.`}
</div>
</div>
);
Conclusion
Building an accessible combobox is a masterclass in frontend state management and browser event handling. By strictly following the WAI-ARIA pattern—managing role="combobox", linking elements with aria-controls and aria-activedescendant, and supporting comprehensive keyboard navigation—you ensure that your application is usable by everyone, regardless of input device or assistive technology.
When building custom design systems, never underestimate the power of native semantics paired with thoughtful accessibility engineering. Your users will thank you.