Beyond the Native Select: Building an Accessible Combobox in React
Learn how to build a fully accessible, WCAG-compliant custom combobox and dropdown component in React using ARIA listbox patterns, keyboard navigation, and screen reader announcements.
Beyond the Native Select: Building an Accessible Combobox in React
Tags: Accessibility, React, TypeScript, UI Components
When building modern web applications, the native HTML <select> element often falls short of design system requirements. We crave custom styling, rich-content options, integrated search filtering, and multi-select capabilities.
However, reaching for a <div> and styling it like a dropdown is a trap. Native elements come with a decade of browser-level accessibility engineering baked in. When you build a custom select or combobox, you take on the responsibility of implementing keyboard navigation, focus management, screen reader announcements, and visual states from scratch.
In this deep dive, we will architect a robust, production-ready custom Combobox component in React and TypeScript. We will adhere strictly to the WAI-ARIA 1.2 Combobox Pattern, ensuring that sighted keyboard users and screen reader users experience a seamless, intuitive UI.
Understanding the Anatomy of a Combobox
Before writing code, we must distinguish between a Select-only dropdown and a Combobox.
- Listbox / Custom Select: A button that toggles a popup list of predefined options. Focus stays on the trigger or moves into the listbox.
- Combobox: An input field combined with a popup. Typing filters the available options, and the input itself acts as the active-descendant anchor.
For our implementation, we are building a Combobox—the more complex of the two, encompassing text input, real-time filtering, keyboard trapping, and option selection.
Core ARIA Attributes Required
To make our component screen-reader friendly, we must wire up a precise contract of ARIA attributes:
role="combobox": Placed on the input element.aria-expanded="true|false": Indicates whether the popup listbox is visible.aria-haspopup="listbox": Informs assistive tech that clicking/interacting reveals a listbox.aria-controls="[id-of-listbox]": Links the input directly to the popup list container.aria-autocomplete="list": Tells the user agent that typing guides option suggestions.role="listbox": Placed on the popup container.role="option": Placed on each individual selectable item.aria-selected="true|false": Indicates the currently chosen option(s).aria-activedescendant="[id-of-focused-option]": Crucial for managing virtual focus without moving real DOM focus away from the input.
Setting Up the State Architecture
Let’s build a robust TypeScript interface for our Combobox props and internal state machine.
import React, { useState, useRef, useEffect, useId, useMemo } from 'react';
export interface Option {
id: string;
label: string;
value: string;
disabled?: boolean;
}
interface ComboboxProps {
options: Option[];
value: Option | null;
onChange: (option: Option | null) => void;
placeholder?: string;
label: string;
}
Inside our component, we need to track several pieces of state:
export const Combobox: React.FC<ComboboxProps> = ({
options,
value,
onChange,
placeholder = 'Select an option...',
label,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState(value ? value.label : '');
const [activeIndex, setActiveIndex] = useState<number>(-1);
const comboboxId = useId();
const listboxId = `${comboboxId}-listbox`;
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// Filter options based on user input query
const filteredOptions = useMemo(() => {
if (!query || (value && query === value.label)) return options;
return options.filter((opt) =>
opt.label.toLowerCase().includes(query.toLowerCase())
);
}, [options, query, value]);
// ...handlers and effects
};
Mastering Virtual Focus with aria-activedescendant
A common anti-pattern in custom dropdowns is shifting DOM focus (element.focus()) to each list item as the user presses the Down arrow. This breaks typing into the input, scrolls the page unexpectedly, and complicates screen reader output.
Instead, we keep DOM focus firmly on the <input> element at all times. We use aria-activedescendant on the input, pointing it to the DOM id of the currently highlighted option in our listbox.
// Determine the ID of the active option for screen readers
const activeDescendantId =
isOpen && activeIndex >= 0 && filteredOptions[activeIndex]
? `${comboboxId}-option-${filteredOptions[activeIndex].id}`
: undefined;
When a user presses the down arrow key, we increment activeIndex, which updates aria-activedescendant. Screen readers will instantly read the newly highlighted option without shifting input focus.
Implementing Robust Keyboard Navigation
Keyboard accessibility requires mapping specific keys to specific behaviors. Let’s write our onKeyDown handler for the input field:
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(0);
} else {
setActiveIndex((prev) =>
prev < filteredOptions.length - 1 ? prev + 1 : 0
);
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(filteredOptions.length - 1);
} else {
setActiveIndex((prev) =>
prev > 0 ? prev - 1 : filteredOptions.length - 1
);
}
break;
case 'Enter':
e.preventDefault();
if (isOpen && activeIndex >= 0 && filteredOptions[activeIndex]) {
selectOption(filteredOptions[activeIndex]);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
setActiveIndex(-1);
if (value) setQuery(value.label);
break;
case 'Tab':
// Close dropdown gracefully on tab out
setIsOpen(false);
break;
default:
if (!isOpen) setIsOpen(true);
break;
}
};
Handling Option Selection
When an option is selected via click or keyboard Enter, we update our state, close the dropdown, and return focus to the input:
const selectOption = (option: Option) => {
onChange(option);
setQuery(option.label);
setIsOpen(false);
setActiveIndex(-1);
inputRef.current?.blur();
};
Managing Focus Loss and Click-Outside Behavior
A robust dropdown must close when the user clicks outside the component boundaries. We can achieve this cleanly using a document click listener paired with useRef:
const containerRef = useRef<HTMLDivElement>(null);
useEffect({
const handleOutsideClick = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
if (value) setQuery(value.label);
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => document.removeEventListener('mousedown', handleOutsideClick);
}, [value]);
Putting It Together: JSX Markup
Now, let’s assemble the JSX, ensuring all ARIA attributes are correctly wired up to our state variables.
return (
<div className="combobox-container" ref={containerRef}>
<label id={`${comboboxId}-label`} className="combobox-label">
{label}
</label>
<div className="combobox-wrapper">
<input
ref={inputRef}
id={comboboxId}
type="text"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeDescendantId}
aria-labelledby={`${comboboxId}-label`}
value={query}
onChange={(e) => {
setQuery(e.target.value);
if (!isOpen) setIsOpen(true);
setActiveIndex(0);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
/>
{isOpen && (
<ul
ref={listRef}
id={listboxId}
role="listbox"
aria-labelledby={`${comboboxId}-label`}
className="combobox-listbox"
>
{filteredOptions.length === 0 ? (
<li className="combobox-option-empty" role="presentation">
No results found
</li>
) : (
filteredOptions.map((option, index) => {
const isSelected = value?.id === option.id;
const isActive = index === activeIndex;
return (
<li
key={option.id}
id={`${comboboxId}-option-${option.id}`}
role="option"
aria-selected={isSelected}
className={`combobox-option ${
isActive ? 'combobox-option--active' : ''
} ${isSelected ? 'combobox-option--selected' : ''}`}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => selectOption(option)}
>
{option.label}
</li>
);
})
)}
</ul>
)}
</div>
</div>
);
Enhancing Screen Reader Announcements
When filtering options dynamically, screen readers may remain silent unless informed of count changes. While modern aria-autocomplete="list" handles much of this, you can elevate experience further by implementing a live region helper for status updates:
<div className="sr-only" aria-live="polite" aria-atomic="true">
{isOpen && `${filteredOptions.length} results available.`}
}
Place this visually hidden helper element inside your wrapper component. As filters narrow down the list, assistive tech will naturally announce the updated count.
Conclusion
Building a custom combobox in React is an exercise in meticulous detail. By pairing proper semantic roles (role="combobox", role="listbox", role="option") with virtual focus management via aria-activedescendant, you deliver an experience that matches—and often exceeds—native form controls.
When building design systems, never sacrifice accessibility for aesthetics. With this pattern in your toolkit, your custom components will be performant, stylish, and welcoming to every user on the web.