Type and Navigate: Building an Accessible Combobox in React from Scratch
Learn how to build a fully accessible, keyboard-friendly combobox and autocomplete component in React using ARIA attributes, robust listbox semantics, and custom hooks.
The combobox is arguably one of the most complex UI patterns in web development. It bridges the gap between a standard text input and a selection menu, requiring dynamic filtering, robust keyboard navigation, strict ARIA attribute management, and seamless screen reader support.
While heavy component libraries like Radix UI or Downshift offer ready-made solutions, building your own combobox from scratch is an invaluable exercise in understanding browser accessibility and fine-grained React state management.
In this deep dive, we will build a robust, accessible autocomplete combobox in React and TypeScript without relying on UI libraries, focusing on listbox semantics, roving focus, typeahead functionality, and live screen reader announcements.
The Anatomy of an Accessible Combobox
Before writing code, let’s establish what makes a combobox accessible. According to the WAI-ARIA Authoring Practices Guide (APG), a combobox consists of an input element that controls a popup (typically a listbox or dialog).
To satisfy screen readers and keyboard users, we must manage several key semantic relationships:
- The Trigger (
input): Must declarerole="combobox",aria-expanded,aria-controls(pointing to the listbox ID), andaria-activedescendant(pointing to the currently highlighted option ID). - The Popup (
ulordiv): Must haverole="listbox"and a unique ID matching the input’saria-controls. - The Options (
liitems): Must haverole="option"and anaria-selectedstate.
Let’s look at how these pieces fit together structurally.
Setting Up the State and Types
We will build a generic autocomplete component that accepts a list of items and an onSelect callback. Let’s start by defining our TypeScript interfaces.
import React, { useState, useRef, useEffect, useId, KeyboardEvent } from 'react';
export interface ComboboxItem {
id: string;
label: string;
value: string;
}
interface ComboboxProps {
items: ComboboxItem[];
value: string;
onChange: (value: string) => void;
onSelect: (item: ComboboxItem) => void;
placeholder?: string;
label: string;
}
Next, let’s initialize our component state. We need to track the input query, whether the dropdown is open, and the index of the currently highlighted option.
export const AccessibleCombobox: React.FC<ComboboxProps> = ({
items,
value,
onChange,
onSelect,
placeholder = "Search...",
label,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const listboxId = useId();
const inputId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
// Filter items based on input value
const filteredItems = items.filter((item) =>
item.label.toLowerCase().includes(value.toLowerCase())
);
// ... component logic continues
};
Implementing Keyboard Navigation
The hallmark of an expert-level UI component is keyboard accessibility. Users should be able to navigate the entire combobox without touching a mouse.
We need to handle the following key events on the input element:
- ArrowDown: Opens the listbox if closed, or moves the highlight down to the next option.
- ArrowUp: Moves the highlight up to the previous option.
- Enter: Selects the currently highlighted option.
- Escape: Closes the listbox.
- Home / End: Jumps to the first or last option.
Here is how we implement this inside a robust onKeyDown handler:
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(0);
} else {
setActiveIndex((prev) =>
prev === null || prev >= filteredItems.length - 1 ? 0 : prev + 1
);
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(filteredItems.length - 1);
} else {
setActiveIndex((prev) =>
prev === null || prev <= 0 ? filteredItems.length - 1 : prev - 1
);
}
break;
case 'Enter':
e.preventDefault();
if (isOpen && activeIndex !== null && filteredItems[activeIndex]) {
handleSelect(filteredItems[activeIndex]);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
setActiveIndex(null);
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(null);
break;
}
};
Managing Focus and aria-activedescendant
There are two primary paradigms for keyboard navigation in listboxes:
- Roving tabindex: Moving the actual DOM focus (
document.activeElement) to each<li>item. - Active descendant: Keeping the focus on the
inputelement and usingaria-activedescendant="id-of-active-item"to inform screen readers which option is currently focused.
For a combobox, active descendant is the recommended pattern because the user’s focus must remain inside the text input so they can continue typing.
Let’s ensure our DOM elements correctly sync with activeIndex:
const handleSelect = (item: ComboboxItem) => {
onSelect(item);
onChange(item.label);
setIsOpen(false);
setActiveIndex(null);
inputRef.current?.focus();
};
// Generate unique ID for the active option
const activeOptionId = activeIndex !== null && filteredItems[activeIndex]
? `${listboxId}-option-${activeIndex}`
: undefined;
Screen Reader Announcements with aria-live
When a user types into an autocomplete input, screen readers need to know how many results are available without constantly interrupting them with excessive noise. We can implement a polite live region that announces state changes.
<div className="sr-only" aria-live="polite" aria-atomic="true">
{isOpen && `${filteredItems.length} results available.`}
}
Placing this visually hidden element (sr-only) in our JSX ensures assistive technologies receive real-time updates as the filter list changes.
Putting It All Together: The Full Component
Here is the complete, cohesive implementation of our accessible combobox component:
import React, { useState, useRef, useId, KeyboardEvent, useEffect } from 'react';
export interface ComboboxItem {
id: string;
label: string;
value: string;
}
interface ComboboxProps {
items: ComboboxItem[];
value: string;
onChange: (value: string) => void;
onSelect: (item: ComboboxItem) => void;
placeholder?: string;
label: string;
}
export const AccessibleCombobox: React.FC<ComboboxProps> = ({
items,
value,
onChange,
onSelect,
placeholder = "Search options...",
label,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const listboxId = useId();
const labelId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const filteredItems = items.filter((item) =>
item.label.toLowerCase().includes(value.toLowerCase())
);
const activeOptionId = activeIndex !== null && filteredItems[activeIndex]
? `${listboxId}-option-${activeIndex}`
: undefined;
const handleSelect = (item: ComboboxItem) => {
onSelect(item);
onChange(item.label);
setIsOpen(false);
setActiveIndex(null);
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 === null || prev >= filteredItems.length - 1 ? 0 : prev + 1
);
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(filteredItems.length - 1);
} else {
setActiveIndex((prev) =>
prev === null || prev <= 0 ? filteredItems.length - 1 : prev - 1
);
}
break;
case 'Enter':
e.preventDefault();
if (isOpen && activeIndex !== null && filteredItems[activeIndex]) {
handleSelect(filteredItems[activeIndex]);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
setActiveIndex(null);
break;
default:
if (!isOpen) setIsOpen(true);
setActiveIndex(null);
break;
}
};
return (
<div className="relative w-full max-w-sm">
<label id={labelId} className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
<div className="relative">
<input
ref={inputRef}
id={listboxId + '-input'}
type="text"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeOptionId}
aria-labelledby={labelId}
value={value}
onChange={(e) => {
onChange(e.target.value);
if (!isOpen) setIsOpen(true);
}}
onKeyDown={handleKeyDown}
onFocus={() => setIsOpen(true)}
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"
/>
{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"
>
{filteredItems.map((item, index) => {
const isHighlighted = index === activeIndex;
return (
<li
key={item.id}
id={`${listboxId}-option-${index}`}
role="option"
aria-selected={isHighlighted}
onClick={() => handleSelect(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>
{/* Live Region for Screen Readers */}
<div className="sr-only" aria-live="polite">
{isOpen ? `${filteredItems.length} suggestions available.` : ''}
</div>
</div>
);
};
Handling Click-Away and Edge Cases
To ensure a polished user experience, clicking outside the combobox component should automatically close the popup. We can achieve this with a simple window click listener or by monitoring onBlur events combined with relatedTarget checks.
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (inputRef.current && !inputRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
Conclusion
Building a custom combobox from scratch gives you ultimate control over styling, behavior, and accessibility. By correctly applying ARIA roles like combobox, listbox, and option, leveraging aria-activedescendant for focus management, and accounting for full keyboard navigation, you ensure that your interactive components are usable by everyone—regardless of how they navigate the web.