Beyond the Native Select: Building an Accessible Custom Dropdown in React
A practical, zero-dependency walkthrough on building a fully accessible custom dropdown select menu in React and TypeScript with keyboard navigation, ARIA attributes, and type-ahead search.
Beyond the Native Select: Building an Accessible Custom Dropdown in React
Every frontend developer eventually faces the same dilemma: the native HTML <select> element is universally accessible, lightweight, and robust, but it is notoriously difficult to style. The moment a designer hands you a mockup with custom fonts, icons, nested grouping, or styled checkmarks next to options, the native element hits a brick wall.
The usual reflex is to reach for a third-party library or hack together a
In this guide, we are going to build a production-ready, zero-dependency custom dropdown select menu in React and TypeScript. We will cover robust open/close state management, explicit ARIA attributes, a roving tabindex (or aria-activedescendant pattern), and lightning-fast type-ahead search.
The Anatomy of an Accessible Select
Before writing a single line of React code, we need to understand what makes a select menu accessible. A truly accessible custom select must satisfy the W3C ARIA Authoring Practices Guide (APG) for a Combobox or Listbox pattern. For a standard select replacement, the Listbox pattern is our North Star.
Here is what our component needs to support:
- Semantic Trigger: A button that toggles the dropdown list and displays the currently selected value.
- Listbox Container: A popup container holding our options, marked up with
role="listbox". - Options: Individual choices marked with
role="option"and the criticalaria-selectedstate. - Keyboard Navigation:
EnterorSpaceto open the menu and select an option.ArrowDown/ArrowUpto navigate items without moving native focus.Escapeto close the menu.Home/Endto jump to the first or last option.- Type-ahead search to jump to options by typing their first letter.
- Focus Management: Returning focus to the trigger button when the menu closes.
TypeScript Interfaces and State Management
Let’s start by defining our data structures. We want our component to be generic and reusable.
import React, { useState, useRef, useEffect, KeyboardEvent, useId } from 'react';
export interface Option {
label: string;
value: string | number;
disabled?: boolean;
}
interface CustomSelectProps {
options: Option[];
value: Option['value'];
onChange: (value: Option['value']) => void;
placeholder?: string;
label: string;
disabled?: boolean;
}
Next, let’s set up our core state. We need to track whether the menu is open, which option is currently highlighted (focused via keyboard), and generate unique IDs for ARIA associations.
export const CustomSelect: React.FC<CustomSelectProps> = ({
options,
value,
onChange,
placeholder = 'Select an option...',
label,
disabled = false,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const labelId = useId();
const listboxId = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const selectedOption = options.find((opt) => opt.value === value);
// ... component logic continues
};
Managing Focus and the Open/Close Cycle
State management in custom dropdowns is notoriously tricky when handling outside clicks. We want the dropdown to close if the user clicks anywhere outside the component.
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);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
When the menu opens, we want to highlight either the currently selected option or the first available option. When it closes, focus must snap back to the trigger button.
const handleOpen = () => {
if (disabled) return;
setIsOpen(true);
const selectedIndex = options.findIndex((opt) => opt.value === value);
setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : 0);
};
const handleClose = () => {
setIsOpen(false);
triggerRef.current?.focus();
};
const handleToggle = () => {
if (isOpen) {
handleClose();
} else {
handleOpen();
}
};
Implementing Keyboard Navigation and ARIA
This is where most custom dropdowns fail. Instead of blindly moving native DOM focus across list items, we use the aria-activedescendant pattern. The focus stays on our trigger or listbox container, while aria-activedescendant points to the ID of the currently highlighted option DOM node. This approach makes screen readers announce options seamlessly without tearing focus away from the input mechanism.
Let’s construct unique IDs for every option and handle the keyboard event listener:
const getOptionId = (index: number) => `${listboxId}-option-${index}`;
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement | HTMLUListElement>) => {
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault();
if (isOpen && highlightedIndex >= 0 && !options[highlightedIndex].disabled) {
onChange(options[highlightedIndex].value);
handleClose();
} else {
handleOpen();
}
break;
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
handleOpen();
} else {
setHighlightedIndex((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) {
handleOpen();
} else {
setHighlightedIndex((prev) => {
let next = prev - 1;
while (next >= 0 && options[next].disabled) {
next--;
}
return next >= 0 ? next : prev;
});
}
break;
case 'Escape':
e.preventDefault();
handleClose();
break;
case 'Home':
e.preventDefault();
if (isOpen) {
const firstValid = options.findIndex(o => !o.disabled);
if (firstValid !== -1) setHighlightedIndex(firstValid);
}
break;
case 'End':
e.preventDefault();
if (isOpen) {
const lastValid = options.map(o => o.disabled).lastIndexOf(false);
if (lastValid !== -1) setHighlightedIndex(lastValid);
}
break;
default:
// Type-ahead search implementation
if (isOpen && e.key.length === 1) {
const char = e.key.toLowerCase();
const startIndex = (highlightedIndex + 1) % options.length;
// Search from current highlighted index to end, then wrap around
let matchIndex = options
.slice(startIndex)
.findIndex((opt) => opt.label.toLowerCase().startsWith(char));
if (matchIndex === -1) {
matchIndex = options
.slice(0, startIndex)
.findIndex((opt) => opt.label.toLowerCase().startsWith(char));
if (matchIndex !== -1) {
matchIndex += startIndex;
}
} else {
matchIndex += startIndex;
}
if (matchIndex !== -1 && !options[matchIndex].disabled) {
setHighlightedIndex(matchIndex);
}
}
break;
}
};
Assembling the JSX and ARIA Markup
Now, let’s assemble the JSX. Notice the precise distribution of ARIA attributes: aria-haspopup, aria-expanded, aria-controls, aria-activedescendant, and role="listbox" combined with role="option".
return (
<div className="relative w-72 font-sans">
{/* Accessible label for screen readers */}
<span id={labelId} className="block text-sm font-medium text-slate-700 mb-1">
{label}
</span>
{/* Trigger Button */}
<button
ref={triggerRef}
type="button"
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-labelledby={`${labelId} ${listboxId}-trigger`}
aria-controls={isOpen ? listboxId : undefined}
onClick={handleToggle}
onKeyDown={handleKeyDown}
id={`${listboxId}-trigger`}
className={`w-full flex items-center justify-between px-3 py-2 text-left bg-white border rounded-lg shadow-sm transition-colors \
${disabled ? 'bg-slate-100 text-slate-400 cursor-not-allowed' : 'border-slate-300 hover:border-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500'}`}
>
<span className={selectedOption ? 'text-slate-900' : 'text-slate-400'}>
{selectedOption ? selectedOption.label : placeholder}
</span>
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Dropdown Options List */}
{isOpen && (
<ul
ref={listboxRef}
id={listboxId}
role="listbox"
aria-labelledby={labelId}
aria-activedescendant={highlightedIndex >= 0 ? getOptionId(highlightedIndex) : undefined}
tabIndex={-1}
onKeyDown={handleKeyDown}
className="absolute z-10 w-full mt-1 bg-white border border-slate-200 rounded-lg shadow-lg max-h-60 overflow-y-auto focus:outline-none"
>
{options.map((option, index) => {
const isSelected = option.value === value;
const isHighlighted = index === highlightedIndex;
return (
<li
id={getOptionId(index)}
key={option.value}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
onClick={() => {
if (!option.disabled) {
onChange(option.value);
handleClose();
}
}}
onMouseEnter={() => !option.disabled && setHighlightedIndex(index)}
className={`px-3 py-2 cursor-pointer flex items-center justify-between text-sm \
${option.disabled ? 'opacity-50 cursor-not-allowed text-slate-400' : ''} \
${isHighlighted && !option.disabled ? 'bg-blue-50 text-blue-900' : 'text-slate-700'} \
${isSelected ? 'font-semibold' : ''}`}
>
<span>{option.label}</span>
{isSelected && (
<svg className="w-4 h-4 text-blue-600" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path
fillRule="evenodd"
d="M16.707 5.293a1.e 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</li>
);
})}
</ul>
)}
</div>
);
};
Verification & Testing
Building accessibility features without testing is like coding blindfolded. To verify your custom dropdown, run through this quick checklist:
- Screen Reader Check: Turn on VoiceOver (macOS) or NVDA (Windows). Focus the select trigger and press
Space. Listen to whether the screen reader announces the listbox role, the highlighted option, and its selection state (aria-selected). - Pure Keyboard Check: Unplug your mouse. Tab to the component, open it using
Enter, navigate up and down using the arrow keys, jump instantly via type-ahead typing (e.g., typing ‘M’ to jump to ‘Mango’), and close/select usingEnterorEscape. - Disabled States: Ensure disabled items are skipped during keyboard arrow navigation and cannot be selected via click.
Pro-Tip: Always pair your custom components with automated integration tests using
@testing-library/reactandjest-axeto catch regressions in ARIA attributes early in your CI/CD pipeline.
Conclusion
Building a custom select menu in React requires more than just styling a <div>. By meticulously handling focus management, adhering to the W3C ARIA Listbox pattern, and implementing smooth keyboard behaviors like type-ahead search and roving highlights, you deliver an elite user experience that works for everyone.
Say goodbye to bulky external dependencies and hello to robust, fully accessible, custom design systems.