Building an Accessible Dropdown Menu in React: Roving Tabindex, Keyboard Navigation, and Type-Ahead
Master the implementation of fully accessible custom dropdown menus in React and TypeScript using roving tabindex patterns, full keyboard navigation, type-ahead search, and robust focus management.
Building an Accessible Dropdown Menu in React: Roving Tabindex, Keyboard Navigation, and Type-Ahead
When building design systems, custom dropdown menus and select components are notoriously difficult to get right. While a native <select> element gives you accessibility out of the box, it offers zero styling flexibility. When design requirements demand rich options, icons, or custom layouts, we reach for custom React components.
However, custom components frequently break native browser behaviors. Screen readers lose track of options, keyboard-only users find themselves trapped, and focus management becomes brittle.
In this guide, we will bridge this gap. We will build a production-grade, highly accessible custom dropdown menu in React and TypeScript, leveraging the roving tabindex pattern, full keyboard navigation, type-ahead searching, and bulletproof focus management.
Understanding the Composite Widget Pattern
According to the WAI-ARIA Authoring Practices Guide (APG), a menu or select component is classified as a composite widget. This means it acts as a single tab stop in the document flow, but contains multiple internally focusable elements.
To achieve this, we use the roving tabindex pattern. Instead of every menu item having tabIndex={0} (which would force the user to press Tab twenty times to cycle through options), only the currently active item has tabIndex={0} or tabIndex={-1}, while all other items have tabIndex={-1}. As the user navigates with the arrow keys, we “rove” the tabIndex={0} property to the newly focused element.
Core Accessibility Requirements
Before writing code, let’s map out our ARIA contract:
- Trigger Button: Must have
aria-haspopup="menu",aria-expanded={isOpen}, andaria-controls="menu-id". - Menu Container: Must have
role="menu"(orrole="listbox"for select components) and be labeled by the trigger. - Menu Items: Must have
role="menuitem"and participate in the roving tabindex scheme. - Focus Management: Focus must move into the menu when opened, and return to the trigger button when closed or when an option is selected.
TypeScript Interfaces and State Setup
Let’s start by defining our component props and internal state types in TypeScript. We will build a generic Dropdown component that manages its open state, active index, and type-ahead buffer.
import React, {
useState,
useRef,
useEffect,
useId,
KeyboardEvent,
MouseEvent
} from 'react';
export interface DropdownOption {
id: string;
label: string;
value: string;
disabled?: boolean;
}
interface AccessibleDropdownProps {
label: string;
options: DropdownOption[];
value: string;
onChange: (value: string) => void;
}
Implementing Roving Tabindex and Keyboard Navigation
To handle arrow key navigation, home, end, escape, and type-ahead search, we need a robust onKeyDown handler on the menu container.
Here is the complete implementation of our accessible dropdown component:
export const AccessibleDropdown: React.FC<AccessibleDropdownProps> = ({
label,
options,
value,
onChange,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(0);
const [typeAheadBuffer, setTypeAheadBuffer] = useState('');
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const menuId = useId();
const labelId = useId();
// Find currently selected option index
const selectedOption = options.find((opt) => opt.value === value);
// Reset item refs array when options change
useEffect(() => {
itemRefs.current = itemRefs.current.slice(0, options.length);
}, [options]);
// Manage focus when menu opens/closes
useEffect(() => {
if (isOpen) {
// Focus the active item when menu opens
const targetIndex = selectedOption
? options.findIndex(o => o.value === value)
: 0;
setActiveIndex(targetIndex >= 0 ? targetIndex : 0);
itemRefs.current[targetIndex >= 0 ? targetIndex : 0]?.focus();
} else {
// Return focus to trigger when closed
triggerRef.current?.focus();
}
}, [isOpen, options, value, selectedOption]);
// Handle type-ahead search timeout
useEffect(() => {
if (!typeAheadBuffer) return;
const timer = setTimeout(() => {
setTypeAheadBuffer('');
}, 500);
return () => clearTimeout(timer);
}, [typeAheadBuffer]);
// Type-ahead matching logic
useEffect(() => {
if (!typeAheadBuffer || !isOpen) return;
const matchIndex = options.findIndex((opt) =>
opt.label.toLowerCase().startsWith(typeAheadBuffer.toLowerCase())
);
if (matchIndex !== -1) {
setActiveIndex(matchIndex);
itemRefs.current[matchIndex]?.focus();
}
}, [typeAheadBuffer, isOpen, options]);
const handleTriggerClick = () => {
setIsOpen((prev) => !prev);
};
const handleOptionClick = (option: DropdownOption) => {
if (option.disabled) return;
onChange(option.value);
setIsOpen(false);
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
switch (event.key) {
case 'ArrowDown':
case 'ArrowUp': {
event.preventDefault();
const direction = event.key === 'ArrowDown' ? 1 : -1;
let nextIndex = activeIndex + direction;
// Wrap around logic
if (nextIndex < 0) nextIndex = options.length - 1;
if (nextIndex >= options.length) nextIndex = 0;
// Skip disabled items
while (options[nextIndex].disabled) {
nextIndex += direction;
if (nextIndex < 0) nextIndex = options.length - 1;
if (nextIndex >= options.length) nextIndex = 0;
if (nextIndex === activeIndex) break;
}
setActiveIndex(nextIndex);
itemRefs.current[nextIndex]?.focus();
break;
}
case 'Home':
event.preventDefault();
setActiveIndex(0);
itemRefs.current[0]?.focus();
break;
case 'End':
event.preventDefault();
setActiveIndex(options.length - 1);
itemRefs.current[options.length - 1]?.focus();
break;
case 'Enter':
case 'Spacebar':
case ' ':
event.preventDefault();
if (options[activeIndex] && !options[activeIndex].disabled) {
handleOptionClick(options[activeIndex]);
}
穆break;
case 'Escape':
event.preventDefault();
setIsOpen(false);
break;
case 'Tab':
// Close menu on tab out to maintain expected roving behavior
setIsOpen(false);
break;
default:
// Printable characters for type-ahead
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
setTypeAheadBuffer((prev) => prev + event.key);
}
break;
}
};
return (
<div className="relative inline-block text-left">
<span id={labelId} className="block text-sm font-medium text-gray-700 mb-1">
{label}
</span>
<button
ref={triggerRef}
id={`${menuId}-trigger`}
type="button"
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls={menuId}
aria-labelledby={`${labelId} ${menuId}-trigger`}
onClick={handleTriggerClick}
className="inline-flex justify-between w-full rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{selectedOption ? selectedOption.label : 'Select an option...'}
<svg className="-mr-1 ml-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<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>
{isOpen && (
<div
ref={menuRef}
id={menuId}
role="menu"
aria-labelledby={labelId}
onKeyDown={handleKeyDown}
className="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10"
>
<div className="py-1" role="none">
{options.map((option, index) => {
const isSelected = option.value === value;
const isActive = index === activeIndex;
return (
<div
key={option.id}
ref={(el) => (itemRefs.current[index] = el)}
role="menuitem"
tabIndex={isActive ? 0 : -1}
aria-disabled={option.disabled}
aria-selected={isSelected}
onClick={() => handleOptionClick(option)}
className={`
cursor-pointer select-none relative py-2 pl-4 pr-9 text-sm outline-none
${isActive ? 'bg-indigo-600 text-white' : 'text-gray-900'}
${option.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-indigo-50 hover:text-indigo-950'}
`}
>
<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-indigo-600'}`}>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M16.707 5.293akeyboard-navigation... a1 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>
</span>
)}
</div>
);
})}
</div>
</div>
)}
</div>
);
};
Key Architectural Takeaways
1. Robust Focus Restoration
When a user selects an option or presses Escape, focus must seamlessly return to the trigger button (triggerRef.current?.focus()). Without this, screen reader users become disoriented, stranded at the top of the document body or trapped inside a closed DOM container.
2. Type-Ahead Search Buffer
Users accustomed to native <select> elements expect to type “Ca” and instantly jump to “California”. By capturing keystrokes that are not navigation keys, storing them in a short-lived typeAheadBuffer, and matching against option.label.toLowerCase(), we deliver a first-class native experience.
3. Skipping Disabled Options
When using Arrow keys to navigate through composite widgets, hitting a disabled option shouldn’t stop navigation or focus a disabled element. Our arrow key handler includes a while loop that skips over option.disabled === true items gracefully.
while (options[nextIndex].disabled) {
nextIndex += direction;
// Handle bounds wrapping...
}
Conclusion
Building custom UI components in React requires looking past visual styling and addressing the underlying interaction model. By combining roving tabindex, precise ARIA attributes, thoughtful focus management, and type-ahead support, you create components that are robust, delightful, and fully accessible to assistive technologies.
When writing your next design system, test your components early and often with screen readers (like VoiceOver or NVDA) and navigate strictly using your keyboard. Your users will thank you!