Dropping In: Crafting an Accessible Menu Component from Scratch
Learn how to build a fully accessible, keyboard-navigable custom dropdown menu in React and TypeScript using roving tabindex and robust click-outside handling.
When building a custom dropdown menu in React, it’s easy to fall into the trap of using a flurry of divs, an onClick handler, and calling it a day. But the moment a keyboard-only user or someone using a screen reader tries to interact with it, the component completely falls apart.
Native HTML <select> elements are notoriously difficult to style consistently across modern browsers and operating systems, which drives developers toward custom implementations. However, custom menus require us to rebuild native browser behaviors from scratch: keyboard navigation, focus management, ARIA attributes, and dismissal on outside clicks.
In this post, we will build a production-grade, fully accessible dropdown menu component in React and TypeScript. We will explore architectural patterns for focus management—specifically comparing roving tabindex versus aria-activedescendant—and implement a robust, type-safe solution.
The Architecture: Roving Tabindex vs. aria-activedescendant
When designing keyboard navigation for a composite widget like a menu, you generally have two choices for managing focus:
aria-activedescendant: The focus remains on the container element (e.g., the menu itself), while anidreference is dynamically shifted to point to the currently active child item via ARIA attributes. Screen readers announce the active item based on this pointer.- Roving Tabindex: The focus actually moves between the DOM elements. Exactly one item in the menu has
tabindex="0"(or the active item hastabindex="0"while all others havetabindex="-1"), and focus is programmatically shifted using.focus()when arrow keys are pressed.
Why Roving Tabindex Wins for Menus
While aria-activedescendant is powerful for complex lists like comboboxes or tree views, it requires massive amounts of boilerplate to ensure assistive technologies reliably catch every state change. Roving tabindex leverages native browser focus mechanics. Because the DOM element literally receives focus, native events, CSS :focus pseudo-classes, and screen reader announcements work out-of-the-box with minimal friction. For a standard dropdown menu, roving tabindex is the gold standard.
Setting Up the Types and State
Let’s start by defining our TypeScript interfaces. A clean component starts with clear data structures.
import React, { useState, useRef, useEffect, KeyboardEvent } from 'react';
export interface MenuItem {
id: string;
label: string;
disabled?: boolean;
onClick: () => void;
}
export interface DropdownMenuProps {
triggerLabel: string;
items: MenuItem[];
}
Our component needs to track a few critical pieces of state:
- Whether the menu is open or closed.
- The index of the currently focused item (for roving tabindex).
export const DropdownMenu: React.FC<DropdownMenuProps> = ({ 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)[]>([]);
// ... component logic continues
};
Implementing Keyboard Navigation and Roving Tabindex
To make our menu navigable via keyboard, we must listen to keydown events on the menu container. The expected accessibility behavior for a menu includes:
- Arrow Down / Arrow Up: Move focus to the next/previous enabled item (wrapping around if necessary).
- Home / End: Jump directly to the first or last item.
- Escape: Close the menu and return focus to the trigger button.
- Enter / Space: Activate the currently focused item.
Here is how we implement this inside a handleKeyDown event handler:
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const enabledIndices = items
.map((item, idx) => (!item.disabled ? idx : -1))
.filter((idx) => idx !== -1);
const currentPosition = enabledIndices.indexOf(activeIndex);
switch (e.key) {
case 'ArrowDown': {
e.preventDefault();
const nextPosition = (currentPosition + 1) % enabledIndices.length;
setActiveIndex(enabledIndices[nextPosition]);
break;
}
case 'ArrowUp': {
e.preventDefault();
const prevPosition = (currentPosition - 1 + enabledIndices.length) % enabledIndices.length;
setActiveIndex(enabledIndices[prevPosition]);
break;
}
case 'Home': {
e.preventDefault();
setActiveIndex(enabledIndices[0]);
break;
}
case 'End': {
e.preventDefault();
setActiveIndex(enabledIndices[enabledIndices.length - 1]);
break;
}
case 'Escape': {
e.preventDefault();
setIsOpen(false);
triggerRef.current?.focus();
break;
}
case 'Tab': {
// Close menu on tab out to maintain standard focus flow
setIsOpen(false);
break;
}
default:
break;
}
};
Next, we need a useEffect hook to synchronize the activeIndex state with actual DOM focus whenever the menu is open or the active index changes:
useEffect(() => {
if (isOpen) {
itemRefs.current[activeIndex]?.focus();
}
}, [isOpen, activeIndex]);
Handling Click-Outside and Focus Trapping
A proper dropdown must close when the user clicks anywhere outside of the component boundaries. We can achieve this cleanly by listening for mousedown events on the document root.
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
menuRef.current &&
!menuRef.current.contains(event.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
Bringing It Together: The Complete Component
Now let’s assemble all the pieces into our final, fully accessible component. Note the strategic placement of ARIA roles (role="menu", role="menuitem") and attributes (aria-haspopup, aria-expanded).
import React, { useState, useRef, useEffect, KeyboardEvent } from 'react';
export interface MenuItem {
id: string;
label: string;
disabled?: boolean;
onClick: () => void;
}
export interface DropdownMenuProps {
triggerLabel: string;
items: MenuItem[];
}
export const DropdownMenu: React.FC<DropdownMenuProps> = ({ 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)[]>([]);
const handleOpen = () => {
setIsOpen(true);
// Find first enabled item
const firstEnabled = items.findIndex((item) => !item.disabled);
setActiveIndex(firstEnabled !== -1 ? firstEnabled : 0);
};
useEffect(() => {
if (isOpen) {
itemRefs.current[activeIndex]?.focus();
}
}, [isOpen, activeIndex]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
menuRef.current &&
!menuRef.current.contains(event.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const enabledIndices = items
.map((item, idx) => (!item.disabled ? idx : -1))
.filter((idx) => idx !== -1);
const currentPosition = enabledIndices.indexOf(activeIndex);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(enabledIndices[(currentPosition + 1) % enabledIndices.length]);
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(enabledIndices[(currentPosition - 1 + enabledIndices.length) % enabledIndices.length]);
break;
case 'Home':
e.preventDefault();
setActiveIndex(enabledIndices[0]);
break;
case 'End':
e.preventDefault();
setActiveIndex(enabledIndices[enabledIndices.length - 1]);
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
triggerRef.current?.focus();
break;
case 'Tab':
setIsOpen(false);
break;
default:
break;
}
};
return (
<div className="relative inline-block text-left">
<button
ref={triggerRef}
aria-haspopup="menu"
aria-expanded={isOpen}
onClick={() => (isOpen ? setIsOpen(false) : handleOpen())}
className="px-4 py-2 bg-blue-600 text-white rounded-md font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400"
>
{triggerLabel}
</button>
{isOpen && (
<div
ref={menuRef}
role="menu"
tabIndex={-1}
onKeyDown={handleKeyDown}
className="absolute left-0 mt-2 w-56 bg-white border border-gray-200 rounded-md shadow-lg py-1 focus:outline-none z-50"
>
{items.map((item, index) => {
const isFocused = index === activeIndex;
return (
<button
key={item.id}
ref={(el) => (itemRefs.current[index] = el)}
role="menuitem"
tabIndex={isFocused ? 0 : -1}
disabled={item.disabled}
aria-disabled={item.disabled}
onClick={() => {
if (!item.disabled) {
item.onClick();
setIsOpen(false);
triggerRef.current?.focus();
}
}}
onMouseEnter={() => !item.disabled && setActiveIndex(index)}
className={`w-full text-left px-4.5 py-2 text-sm ${
item.disabled
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-700 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none'
}`}
>
{item.label}
</button>
);
})}
</div>
)}
</div>
);
};
Conclusion
Building accessible custom components forces us to appreciate the hidden complexities native HTML elements handle automatically. By implementing a roving tabindex pattern, handling click-outside scenarios cleanly with refs, and wiring up precise keyboard event listeners, we’ve created a dropdown menu that functions reliably for mouse, touch, and keyboard/screen reader users alike.
When designing your next design system, remember that accessibility isn’t an afterthought or a quick ARIA patch—it is core architectural functionality.