Select and Arrow: Building an Accessible Custom Dropdown in React from Scratch
Learn how to build a production-grade, accessible custom dropdown select component in React complete with ARIA listbox semantics, full keyboard navigation, and type-ahead search.
When building design systems, the native HTML <select> element is often the first thing developers want to replace. Native selects are notorious for being difficult to style consistently across browsers and operating systems. However, tossing out native elements means losing decades of built-in accessibility, screen reader support, keyboard navigation, and mobile OS optimizations.
To build a custom dropdown select component that doesn’t alienate keyboard users or screen reader tech, we need to implement the WAI-ARIA Listbox Pattern.
In this guide, we will build a production-ready, fully accessible custom select component in React and TypeScript featuring:
- Complete ARIA attributes (
role="combobox",role="listbox",role="option", etc.) - Robust keyboard navigation (Arrow keys, Home, End, Enter, Escape, Space)
- Type-ahead search (jumping to options by typing letters)
- Outside click and focus management
The Architecture of an Accessible Select
A custom select consists of two primary interactive parts:
- The Trigger (Combobox): A button-like element that displays the currently selected value and toggles the dropdown menu. It requires
aria-haspopup="listbox",aria-expanded, andaria-controls. - The Dropdown (Listbox): A floating container holding the selectable items. It requires
role="listbox"and contains options withrole="option".
Let’s start by defining our TypeScript interfaces.
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
export interface CustomSelectProps {
options: SelectOption[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
label: string;
disabled?: boolean;
}
Step 1: Setting Up State and Refs
Our component needs to track whether the menu is open, which option is currently highlighted (focused via keyboard, distinct from selected), and references to DOM nodes for focus management.
import React, { useState, useRef, useEffect, useId, useCallback } from 'react';
export const CustomSelect: React.FC<CustomSelectProps> = ({
options,
value,
onChange,
placeholder = 'Select an option...',
label,
disabled = false,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const [searchString, setSearchString] = useState('');
const idPrefix = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const listboxRef = useRef<HTMLUListboxElement>(null);
const selectedOption = options.find((opt) => opt.value === value);
// ... hook implementations to follow
};
Step 2: Implementing WAI-ARIA Attributes
Screen readers rely entirely on correct ARIA attributes to understand state changes. Let’s wire up the trigger and the listbox container correctly.
// Inside CustomSelect return statement
return (
<div className="relative w-72">
{/* Visual or sr-only label */}
<label id={`${idPrefix}-label`} className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
{/* Combobox Trigger */}>
<button
ref={triggerRef}
type="button"
role="combobox"
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-controls={`${idPrefix}-listbox`}
aria-labelledby={`${idPrefix}-label ${idPrefix}-trigger`}
id={`${idPrefix}-trigger`}
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm text-left focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
>
<span id={`${idPrefix}-value">
{selectedOption ? selectedOption.label : <span className="text-gray-400">{placeholder}</span>}
</span>
<svg className="w-5 h-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
{/* Listbox Dropdown */}
{isOpen && (
<ul
ref={listboxRef}
id={`${idPrefix}-listbox`}
role="listbox"
aria-labelledby={`${idPrefix}-label`}
tabIndex={-1}
className="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"
>
{options.map((option, index) => {
const isSelected = option.value === value;
const isActive = index === activeIndex;
const optionId = `${idPrefix}-option-${index}`;
return (
<li
key={option.value}
id={optionId}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
onClick={() => {
if (!option.disabled) {
onChange(option.value);
setIsOpen(false);
triggerRef.current?.focus();
}
}}
className={`cursor-default select-none relative py-2 pl-3 pr-9 ${
isActive ? 'bg-blue-600 text-white' : 'text-gray-900'
} ${option.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-blue-50 hover:text-blue-900'}`}
>
<span className={`block truncate ${isSelected ? 'font-semibold' : 'font-normal'}`}>
{option.label}
</span>
{isSelected && (
<span className={`absolute inset-y-0 right-0 flex items-center pr-4 ${isActive ? 'text-white' : 'text-blue-600'}`}>
✓
</span>
)}
</li>
);
})}
</ul>
)}
</div>
);
Step 3: Comprehensive Keyboard Navigation
Keyboard accessibility is where most custom dropdowns fail. Users expect native-like interactions:
- Enter / Space: Opens the menu or selects the active option.
- Arrow Down / Arrow Up: Opens the menu and cycles through options.
- Home / End: Jumps to the first or last option.
- Escape: Closes the menu without selecting.
- Type-ahead: Typing characters jumps focus to matching options.
Let’s implement a robust keyboard event handler for the trigger and listbox:
const handleKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
// Highlight current selection or first item
const selectedIdx = options.findIndex(o => o.value === value);
setActiveIndex(selectedIdx >= 0 ? selectedIdx : 0);
} else if (activeIndex >= 0 && !options[activeIndex].disabled) {
onChange(options[activeIndex].value);
setIsOpen(false);
triggerRef.current?.focus();
}
break;
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(0);
} else {
setActiveIndex((prev) => {
let next = prev + 1;
while (next < options.length && options[next].disabled) {
next++;
}
return next < options.length ? next : prev;
});
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setActiveIndex(options.length - 1);
} else {
setActiveIndex((prev) => {
let next = prev - 1;
while (next >= 0 && options[next].disabled) {
next--;
}
return next >= 0 ? next : prev;
});
}
break;
case 'Home':
e.preventDefault();
if (isOpen) {
const firstValid = options.findIndex(o => !o.disabled);
if (firstValid >= 0) setActiveIndex(firstValid);
}
break;
case 'End':
e.preventDefault();
if (isOpen) {
let lastValid = options.length - 1;
while (lastValid >= 0 && options[lastValid].disabled) {
lastValid--;
}
if (lastValid >= 0) setActiveIndex(lastValid);
}
break;
case 'Escape':
if (isOpen) {
e.preventDefault();
setIsOpen(false);
triggerRef.current?.focus();
}
break;
default:
// Handle type-ahead search
if (isOpen && e.key.length === 1) {
const char = e.key.toLowerCase();
setSearchString(prev => {
const newSearch = prev + char;
const matchingIndex = options.findIndex((opt, idx) =>
idx >= activeIndex && opt.label.toLowerCase().startsWith(newSearch) && !opt.disabled
);
if (matchingIndex !== -1) {
setActiveIndex(matchingIndex);
} else {
// Wrap around search
const wrapIndex = options.findIndex(opt =>
opt.label.toLowerCase().startsWith(newSearch) && !opt.disabled
);
if (wrapIndex !== -1) setActiveIndex(wrapIndex);
}
// Clear search buffer after 500ms of inactivity
setTimeout(() => setSearchString(''), 500);
return newSearch;
});
}
break;
}
};
Attach this onKeyDown handler to both the trigger button and the wrapper element so it catches events regardless of where focus sits.
Step 4: Managing Focus and Active Descendants
To ensure screen readers announce highlighted options correctly, we can use aria-activedescendant on our combobox or manage physical focus. Since our dropdown is a standard <ul>, using aria-activedescendant tied to the active list item ID provides an exceptionally smooth screen reader experience.
Update the trigger element to include aria-activedescendant:
<button
// ... other props
aria-activedescendant={isOpen && activeIndex >= 0 ? `${idPrefix}-option-${activeIndex}` : undefined}
onKeyDown={handleKeyDown}
>
Handling Outside Clicks
When a user clicks outside the open select dropdown, it should gracefully close. We can implement a simple window event listener:
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (triggerRef.current && !triggerRef.current.contains(event.target as Node) &&
listboxRef.current && !listboxRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
window.addEventListener('mousedown', handleClickOutside);
}
return () => {
window.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
Conclusion
Building a custom select component requires attention to detail, but adhering to the WAI-ARIA Listbox specification ensures your UI remains inclusive for screen reader users and power keyboard navigators alike.
By combining explicit ARIA roles (combobox, listbox, option), aria-activedescendant, robust arrow-key handlers, and type-ahead searching, you deliver an experience that matches—and often exceeds—native browser controls while maintaining complete design system freedom.