Beyond the Native Select: Crafting an Accessible Combobox in React
A step-by-step tutorial on building a fully accessible custom select dropdown in React using ARIA combobox patterns, keyboard navigation, and TypeScript.
Beyond the Native Select: Crafting an Accessible Combobox in React
While the native HTML <select> element is undeniably accessible by default, it notoriously resists custom styling, complex layouts, and dynamic iconography. When product requirements demand a polished, branded dropdown component, developers often reach for a <div> soup with click handlers—inadvertently breaking keyboard navigation, screen reader announcements, and focus management.
To build a custom select menu that rivals the native element’s accessibility, we must implement the WAI-ARIA Combobox Pattern.
In this tutorial, we will build a robust, accessible custom select dropdown from scratch in React and TypeScript. We will cover listbox roles, aria-activedescendant management, type-ahead search, and comprehensive keyboard interactions.
The Architecture of an Accessible Combobox
Before writing code, let’s establish what makes a custom select accessible. According to the WAI-ARIA Authoring Practices Guide (APG), a combobox widget is an input that controls another element, such as a listbox, which can dynamically popup to help the user set the value of the input.
Our component will consist of three main structural pieces:
- The Trigger Button (or Combobox Input): Receives initial focus, opens the dropdown, and displays the current selection.
- The Listbox Popup: A container holding the options with
role="listbox". - The Options: Individual selectable items with
role="option".
Core ARIA Attributes We Need
role="combobox": Applied to the trigger element to inform assistive technologies of its behavior.aria-expanded: Tells the screen reader whether the dropdown is open (true) or closed (false).aria-haspopup="listbox": Indicates that the trigger controls a listbox popup.aria-controls: Links the trigger directly to theidof the listbox element.aria-activedescendant: Points to theidof the currently focused option within the virtual list, allowing the screen reader to announce focused items without shifting real DOM focus away from the input.
Step 1: Setting up Types and Component State
Let’s start by defining our TypeScript interfaces and setting up the basic React state hooks. We’ll need state for:
- Whether the dropdown is open (
isOpen). - The currently selected item (
selectedValue). - The currently highlighted option for keyboard navigation (
activeIndex).
import React, { useState, useRef, useEffect, KeyboardEvent } from 'react';
export interface Option {
value: string;
label: string;
disabled?: boolean;
}
interface CustomSelectProps {
options: Option[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
label: string;
}
Step 2: Building the Component Skeleton
Here is the initial component structure with essential ref references and basic event handlers.
export const CustomSelect: React.FC<CustomSelectProps> = ({
options,
value,
onChange,
placeholder = 'Select an option...',
label,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const containerRef = useRef<HTMLDivElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const selectedOption = options.find((opt) => opt.value === value);
// Close dropdown when clicking outside
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);
}, []);
return (
<div className="custom-select-container" ref={containerRef}>
<label id="custom-select-label" className="select-label">
{label}
</label>
{/* Trigger Button */}
<button
ref={triggerRef}
type="button"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls="custom-select-listbox"
aria-labelledby="custom-select-label custom-select-button"
id="custom-select-button"
onClick={() => setIsOpen(!isOpen)}
>
{selectedOption ? selectedOption.label : placeholder}
</button>
{/* Popup Listbox */}
{isOpen && (
<ul
ref={listboxRef}
id="custom-select-listbox"
role="listbox"
aria-labelledby="custom-select-label"
>
{options.map((option, index) => {
const isSelected = option.value === value;
const isHighlighted = index === activeIndex;
return (
<li
key={option.value}
id={`option-${index}`}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
onClick={() => {
if (!option.disabled) {
onChange(option.value);
setIsOpen(false);
triggerRef.current?.focus();
}
}}
>
{option.label}
</li>
);
})}
</ul>
)}
</div>
);
};
Step 3: Implementing aria-activedescendant Keyboard Navigation
When standard listboxes are rendered, moving focus (document.activeElement) away from the trigger can cause layout shifts or loss of context. Instead, we keep focus on the trigger (or combobox container) and use aria-activedescendant to inform assistive tech which list item is “focused.”
Let’s add the onKeyDown handler to our trigger button to manage navigation via Arrow keys, Home, End, Enter, and Escape.
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
switch (e.key) {
case 'Enter':
case ' ':'
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
// Set initial highlight to current selection or first item
const currentIndex = options.findIndex((opt) => opt.value === value);
setActiveIndex(currentIndex >= 0 ? currentIndex : 0);
} else if (activeIndex >= 0 && !options[activeIndex].disabled) {
onChange(options[activeIndex].value);
setIsOpen(false);
}
break;
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(0);
} else {
setActiveIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0
);
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(options.length - 1);
} else {
setActiveIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1
);
}
break;
case 'Home':
e.preventDefault();
if (isOpen) setActiveIndex(0);
break;
case 'End':
e.preventDefault();
if (isOpen) setActiveIndex(options.length - 1);
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
break;
default:
break;
}
};
Now, update the trigger button props to include aria-activedescendant and our new onKeyDown handler:
<button
ref={triggerRef}
type="button"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls="custom-select-listbox"
aria-labelledby="custom-select-label custom-select-button"
aria-activedescendant={isOpen && activeIndex >= 0 ? `option-${activeIndex}` : undefined}
id="custom-select-button"
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
>
{selectedOption ? selectedOption.label : placeholder}
</button>
Step 4: Adding Type-Ahead Search
Users expect to press a letter key on their keyboard and instantly jump to an option starting with that letter. Let’s build a type-ahead buffer that resets after a short timeout.
// Add state for type-ahead
const [typeAheadBuffer, setTypeAheadBuffer] = useState('');
useEffect(() => {
if (!typeAheadBuffer) return;
const timer = setTimeout(() => {
setTypeAheadBuffer('');
}, 500);
return () => clearTimeout(timer);
}, [typeAheadBuffer]);
// Inside handleKeyDown switch statement, add a default case:
default:
if (isOpen && e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
const newBuffer = typeAheadBuffer + e.key;
setTypeAheadBuffer(newBuffer);
const matchingIndex = options.findIndex((opt) =>
opt.label.toLowerCase().startsWith(newBuffer.toLowerCase())
);
if (matchingIndex !== -1) {
setActiveIndex(matchingIndex);
}
}
break;
Step 5: Visual Styling and Scroll Synchronization
Using aria-activedescendant requires visual styling to reflect the active item using CSS classes rather than native pseudo-classes like :focus. We also want to ensure that as the user presses Arrow keys, the list container automatically scrolls to keep the active option in view.
// Scroll active item into view when activeIndex changes
useEffect(() => {
if (isOpen && activeIndex >= 0 && listboxRef.current) {
const activeItem = listboxRef.current.children[activeIndex] as HTMLElement;
if (activeItem) {
activeItem.scrollIntoView({ block: 'nearest' });
}
}
}, [activeIndex, isOpen]);
Essential CSS
.custom-select-container {
position: relative;
width: 280px;
font-family: system-ui, sans-serif;
}
.select-label {
display: block;
font-weight: 600;
margin-bottom: 6px;
font-size: 0.875rem;
}
button[role="combobox"] {
width: 100%;
padding: 10px 14px;
background: #fff;
border: 1px solid #cbd5e1;
border-radius: 6px;
text-align: left;
cursor: pointer;
font-size: 1rem;
}
button[role="combobox"]:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
ul[role="listbox"] {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 240px;
overflow-y: auto;
background: #fff;
border: 1px solid #cbd5e1;
border-radius: 6px;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
margin: 0;
padding: 4px;
list-style: none;
z-index: 50;
}
li[role="option"] {
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.95rem;
}
/* Highlight active item via aria-activedescendant or hover */
li[role="option"][aria-selected="true"] {
background: #eff6ff;
color: #1d4ed8;
font-weight: 500;
}
/* We can style active state based on index or custom data attribute */
li[role="option"][aria-disabled="true"] {
color: #94a3b8;
cursor: not-allowed;
}
Pro-Tip: To make styling active items even cleaner, you can attach a custom data attribute like
data-active={isHighlighted}to your<li>elements and target them directly in CSS:li[role="option"][data-active="true"] { background: #f1f5f9; }.
Conclusion
Building a custom select dropdown in React requires looking past surface-level aesthetics and honoring established interaction patterns. By implementing the WAI-ARIA combobox pattern, managing aria-activedescendant, providing comprehensive keyboard support (including Home, End, and type-ahead), and keeping state synchronized, you ensure that your design system components are robust and delightful for every user.
Checklist for Production Readiness:
- Screen reader tested (VoiceOver, NVDA, or JAWS).
- Handles disabled options cleanly (skips them during arrow key navigation).
- Closes on blur / click outside.
- Fully responsive and supports custom option renderers if needed.