Dropdowns Done Right: Building an Accessible Menu in React
Learn how to build a production-ready, fully accessible custom React dropdown menu from scratch using TypeScript, complete with ARIA attributes, robust keyboard navigation, and focus management.
Dropdowns Done Right: Building an Accessible Menu in React
Dropdown menus are a staple of modern web applications. Yet, if you inspect custom dropdowns across the web, an alarming number of them fail basic accessibility audits. They trap screen readers, ignore keyboard shortcuts, or lose focus management the moment a user presses the Escape key.
Building an accessible dropdown menu—often referred to in the WAI-ARIA authoring practices as a Menu Button pattern—requires careful attention to semantics, keyboard interactions, and focus states.
In this practical walkthrough, we’ll build a robust, fully accessible dropdown menu component in React and TypeScript. We will cover semantic ARIA attributes, compare navigation strategies like roving tabindex versus aria-activedescendant, and implement bulletproof keyboard handling.
The Anatomy of an Accessible Dropdown
Before writing any code, let’s establish what makes a dropdown accessible:
- Semantic Trigger: The button that toggles the menu must communicate its state (
aria-expanded) and control the menu (aria-controls). - Menu Semantics: The container holding the options must have
role="menu". - Item Semantics: Each interactive option must have
role="menuitem". - Focus Management: When the menu opens, focus should move directly to the first item (or trigger). When it closes, focus must return to the trigger button.
- Keyboard Support: Users must be able to navigate using
ArrowDown,ArrowUp,Home,End,Escape,Enter, andSpace.
Setting Up the TypeScript Types and State
Let’s start by defining our component props and internal state structure. We’ll use a functional React component with TypeScript.
import React, { useState, useRef, useEffect, KeyboardEvent } from 'react';
export interface DropdownItem {
id: string;
label: string;
onClick: () => void;
disabled?: boolean;
}
interface AccessibleDropdownProps {
triggerLabel: string;
items: DropdownItem[];
}
Strategy: Roving Tabindex vs. aria-activedescendant
When building list-based components, developers generally choose between two primary accessibility patterns for managing focus:
aria-activedescendant: Focus remains on the container element (role="menu"), and an attribute points to the ID of the currently active child.- Roving Tabindex: Only one item in the menu at a time has
tabIndex={0}, while all other items havetabIndex={-1}. Focus is programmatically moved to the active DOM element.
While aria-activedescendant is powerful for complex virtualized lists, roving tabindex is generally easier to implement correctly in standard React components because it relies on native browser focus handling (element.focus()). We will use the roving tabindex approach for our implementation.
Building the Component
Here is the complete implementation of our accessible dropdown component:
export const AccessibleDropdown: React.FC<AccessibleDropdownProps> = ({
triggerLabel,
items,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(0);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
// Reset active index when menu opens
const handleOpen = () => {
setIsOpen(true);
setActiveIndex(0);
};
// Close menu and return focus to trigger
const handleClose = () => {
setIsOpen(false);
triggerRef.current?.focus();
};
// Focus the active item whenever activeIndex changes while open
useEffect(() => {
if (isOpen) {
itemRefs.current[activeIndex]?.focus();
}
}, [activeIndex, isOpen]);
// Handle outside clicks to close the menu
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
menuRef.current &&
!menuRef.current.contains(event.target as Node) &&
!triggerRef.current?.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'Escape':
event.preventDefault();
handleClose();
break;
case 'ArrowDown':
event.preventDefault();
setActiveIndex((prev) => (prev + 1) % items.length);
break;
case 'ArrowUp':
event.preventDefault();
setActiveIndex((prev) => (prev - 1 + items.length) % items.length);
break;
case 'Home':
event.preventDefault();
setActiveIndex(0);
break;
case 'End':
event.preventDefault();
setActiveIndex(items.length - 1);
break;
default:
break;
}
};
return (
<div className="relative inline-block">
<button
ref={triggerRef}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls="accessible-menu"
onClick={() => (isOpen ? handleClose() : handleOpen())}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' && !isOpen) {
e.preventDefault();
handleOpen();
}
}}
className="px-4 py-2 bg-blue-600 text-white rounded font-medium focus:outline-none focus:ring-2 focus:ring-blue-400"
>
{triggerLabel} ▾
</button>
{isOpen && (
<div
id="accessible-menu"
ref={menuRef}
role="menu"
tabIndex={-1}
onKeyDown={handleKeyDown}
className="absolute left-0 mt-2 w-48 bg-white border border-gray-200 rounded shadow-lg py-1 z-10"
>
{items.map((item, index) => (
<button
key={item.id}
ref={(el) => (itemRefs.current[index] = el)}
role="menuitem"
tabIndex={index === activeIndex ? 0 : -1}
disabled={item.disabled}
onClick={() => {
item.onClick();
handleClose();
}}
className={`w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none ${
item.disabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{item.label}
</button>
))}
</div>
)}
</div>
);
};
Deep Dive: Key Accessibility Features Explained
1. ARIA Attributes on the Trigger
aria-haspopup="menu": Informs assistive technologies that activating this button will display a menu.aria-expanded={isOpen}: Dynamically communicates whether the menu is currently visible (true) or hidden (false).aria-controls="accessible-menu": Binds the button to the DOM ID of the menu container.
2. Managing Focus and the Roving Tabindex
Notice how each menu item sets its tabIndex dynamically:
tabIndex={index === activeIndex ? 0 : -1}
When the user presses ArrowDown or ArrowUp, we update activeIndex, which triggers our useEffect hook to call .focus() on the newly active DOM node. This ensures that keyboard-only users and screen reader users move sequentially through the menu items without jumping out of the document flow.
3. Escape-Key Handling and Focus Restoration
One of the most common accessibility bugs is the “focus drop.” When a user closes a dropdown via the Escape key, focus must be returned immediately to the element that originally triggered the menu:
case 'Escape':
event.preventDefault();
handleClose();
break;
Coupled with triggerRef.current?.focus() inside handleClose(), this ensures seamless keyboard continuity.
Conclusion
Building accessible components in React doesn’t have to be overwhelming. By adhering to WAI-ARIA standards, thoughtfully managing focus states with refs, and anticipating keyboard interactions like arrow navigation and escape handling, you can deliver a polished experience for every user.
Always test your components with both a screen reader (such as VoiceOver or NVDA) and purely via keyboard navigation (Tab, Shift+Tab, Arrow keys, and Escape) to verify that your implementation holds up in real-world scenarios.