The ComboBox Conundrum: Building an Accessible Autocomplete Component in React
{"title": "The ComboBox Conundrum: Building an Accessible Autocomplete Component in React", "summary": "Learn how to build a fully accessible, keyboard-navigable combobox and autocomplete component in React and TypeScript from scratch, following strict WAI-ARIA authoring practices.
{“title”: “The ComboBox Conundrum: Building an Accessible Autocomplete Component in React”, “summary”: “Learn how to build a fully accessible, keyboard-navigable combobox and autocomplete component in React and TypeScript from scratch, following strict WAI-ARIA authoring practices.”, “tags”: [“Accessibility”, “React”, “TypeScript”, “UI Components”, “Frontend”], “body”: “## Introduction
If you have ever attempted to build a custom combobox or autocomplete component from scratch, you likely ran into a wall of complexity. At first glance, it seems simple: an input field, a dropdown list, and some filtering logic. But once you factor in screen readers, robust keyboard navigation, dynamic focus management, and typeahead behaviors, the seemingly simple UI component turns into an intricate state machine.
Many developers default to heavy component libraries to solve this, but those libraries often ship with massive bundle sizes and rigid styling paradigms. Building your own accessible combobox gives you absolute control over styling, behavior, and performance.
In this guide, we will break down the WAI-ARIA Authoring Practices Guide (APG) for a combobox and implement a bulletproof, keyboard-navigable autocomplete component in React and TypeScript without relying on third-party UI libraries.
Understanding the WAI-ARIA Combobox Pattern
Before writing a single line of React code, we need to understand what makes a combobox a combobox in the eyes of assistive technologies. According to the WAI-ARIA 1.2 specification, a combobox is an input widget that controls another element, such as a listbox or grid, that can dynamically pop up to help the user set the value of the input.
To make our component fully accessible, we must manage a delicate interplay of ARIA attributes:
-
The Input Element:
role=\"combobox\": Identifies the element as a combobox.aria-expanded: Indicates whether the popup (listbox) is currently open (trueorfalse).aria-haspopup=\"listbox\": Informs the screen reader that the popup is a listbox.aria-controls: Points to theidof the popup element.aria-autocomplete=\"list\": Specifies that the suggestion behavior provides a list of choices.aria-activedescendant: Identifies theidof the currently highlighted option within the listbox (crucial for screen reader announcements without shifting actual DOM focus).
-
The Listbox Element:
role=\"listbox\": Identifies the popup container.id: Must match thearia-controlsvalue on the input.
-
The Option Elements:
role=\"option\": Identifies each selectable item.aria-selected: Indicates whether the option is currently focused or selected.id: Unique identifier referenced by the input’saria-activedescendant.
Defining the TypeScript Interfaces
Let’s start by setting up our types. We want our component to be generic enough to handle arbitrary items while strictly typing our state transitions and props.
import React, { useState, useRef, useEffect, useId, KeyboardEvent } from 'react';
export interface ComboboxItem {
id: string;
label: string;
[key: string]: any;
}
export interface ComboboxProps {
items: ComboboxItem[];
value: string;
onChange: (value: string) => void;
onSelect: (item: ComboboxItem) => void;
placeholder?: string;
label: string;
}
State Management and Hook Architecture
Our combobox needs to track several distinct pieces of state:
- Whether the popup listbox is open.
- The index of the currently highlighted option (for keyboard navigation).
- The filtered items based on the user’s input.
export const AccessibleCombobox: React.FC<ComboboxProps> = ({
items,
value,
onChange,
onSelect,
placeholder = 'Search...',
label,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const inputRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const uniqueId = useId();
const inputId = `combobox-input-${uniqueId}`;
const listboxId = `combobox-listbox-${uniqueId}`;
// Filter items based on current input value
const filteredItems = items.filter((item) =>
item.label.toLowerCase().includes(value.toLowerCase())
);
// Reset active index when filtered items change or list closes
useEffect(() => {
setActiveIndex(-1);
}, [value, isOpen]);
// ... event handlers go here
};
Implementing Robust Keyboard Navigation
Keyboard navigation is where most custom comboboxes fail. Users expect specific behaviors:
ArrowDown/ArrowUp: Moves focus through the suggestion list without moving the text cursor out of the input.Enter: Selects the currently highlighted option.Escape: Closes the listbox.Home/End: Jumps to the first or last suggestion.
Here is how we implement this using aria-activedescendant:
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setActiveIndex((prev) =>
prev < filteredItems.length - 1 ? prev + 1 : 0
);
}
break;
case 'ArrowUp':
e.preventDefault();
if (isOpen) {
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 'Tab':
// Allow natural tab behavior, but close the listbox
setIsOpen(false);
break;
default:
if (!isOpen) setIsOpen(true);
break;
}
};
const selectItem = (item: ComboboxItem) => {
onSelect(item);
onChange(item.label);
setIsOpen(false);
setActiveIndex(-1);
inputRef.current?.focus();
};
Why
aria-activedescendant? Moving standard DOM focus (document.activeElement) to dropdown options while a user is typing breaks the input flow.aria-activedescendantkeeps DOM focus firmly on the<input>, while telling screen readers to announce the element whose ID matches the active state.
Assembling the JSX Structure
Now let’s wire everything together into a clean, accessible JSX template.
return (
<div className=\"relative w-full max-w-sm\">
<label htmlFor={inputId} className=\"block text-sm font-medium text-gray-700 mb-1\">
{label}
</label>
<div className=\"relative\">
<input
ref={inputRef}
id={inputId}
type=\"text\"
role=\"combobox\"
aria-expanded={isOpen}
aria-haspopup=\"listbox\"
aria-controls={listboxId}
aria-autocomplete=\"list\"
aria-activedescendant={
isOpen && activeIndex >= 0 ? `${listboxId}-item-${activeIndex}` : undefined
}
value={value}
onChange={(e) => {
onChange(e.target.value);
if (!isOpen) setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className=\"w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500\"
/>
{isOpen && filteredItems.length > 0 && (
<ul
ref={listboxRef}
id={listboxId}
role=\"listbox\"
className=\"absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto focus:outline-none\"
>
{filteredItems.map((item, index) => {
const isHighlighted = index === activeIndex;
const itemId = `${listboxId}-item-${index}`;
return (
<li
key={item.id}
id={itemId}
role=\"option\"
aria-selected={isHighlighted}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => selectItem(item)}
className={`px-3 py-2 cursor-pointer text-sm ${
isHighlighted ? 'bg-blue-600 text-white' : 'text-gray-900 hover:bg-gray-100'
}`}
>
{item.label}
</li>
);
})}
</ul>
)}
</div>
</div>
);
Handling Click-Outside and Focus Loss
A common edge case in autocomplete components is managing when the popup should close. If a user clicks outside the component or tabs away, the listbox must disappear.
We can implement a simple document-level click listener using an effect:
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
inputRef.current &&
!inputRef.current.contains(event.target as Node) &&
listboxRef.current &&
!listboxRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
Conclusion
Building an accessible combobox from scratch requires careful adherence to WAI-ARIA specifications, meticulous keyboard event handling, and thoughtful state management. By leveraging aria-activedescendant and keeping DOM focus where it belongs (on the input), you ensure that screen reader users and keyboard-only users experience a seamless interaction.
With this foundation in place, you can expand your component to support asynchronous data fetching, multi-select tags, or custom option rendering without sacrificing accessibility or bloating your project with heavy dependencies.”}