Beyond the Basics: Crafting an Accessible Menu and Popover Component in React
Learn how to build a production-ready, fully accessible dropdown menu and popover component from scratch in React using TypeScript, handling roving tabindex, ARIA attributes, and keyboard navigation.
Beyond the Basics: Crafting an Accessible Menu and Popover Component in React
Dropdown menus and popovers are ubiquitous interface patterns. From user profile menus to complex context actions, every web application needs them. However, if you look under the hood of many custom React implementations, you will find a ticking time bomb of accessibility and usability issues.
Common pitfalls include:
- No screen reader support: Missing or incorrect ARIA roles, states, and properties.
- Keyboard trap or complete keyboard neglect: Users cannot navigate menu items using arrow keys, or they get trapped inside the popover.
- Poor focus management: Focus is lost when the menu closes, dropping the user back to the top of the document.
- Missing click-outside and escape key handlers: The popover stays open stubbornly when the user clicks away or presses
Escape.
In this guide, we will build a robust, accessible, keyboard-navigable dropdown menu and popover component from scratch in React and TypeScript. We will leverage native browser behavior, standard WAI-ARIA authoring practices, and clean React hooks.
The Anatomy of an Accessible Popover
Before writing code, let’s establish what makes a popover accessible according to the WAI-ARIA Menu and Menu Button Pattern:
- The Trigger (
aria-haspopup,aria-expanded): The button that toggles the popover must inform assistive technologies that it controls a popup and whether that popup is currently expanded. - The Container (
role="menu"orrole="dialog"): The popup container needs an explicit role so screen readers announce its purpose immediately upon opening. - Focus Management: When the menu opens, focus must move directly to the first interactive item (or the container itself). When it closes, focus must return to the trigger.
- Roving Tabindex & Arrow Navigation: Users expect to use the
ArrowDownandArrowUpkeys to cycle through options, rather than pressing theTabkey repeatedly. - Dismissal (
Escapekey & Outside Clicks): PressingEscapeor clicking anywhere outside the popover must close it instantly.
Let’s implement these requirements step by step.
Step 1: Setting Up the Types and State
We’ll start with a clean TypeScript interface for our component props. We need a way to manage the open/closed state, anchor positioning, and callbacks.
import React, {
useState,
useRef,
useEffect,
useCallback,
KeyboardEvent,
ReactNode,
} from 'react';
interface PopoverMenuProps {
trigger: (isOpen: boolean, triggerRef: React.RefObject<HTMLButtonElement>) => ReactNode;
children: ReactNode;
align?: 'start' | 'end';
}
Our trigger prop is a render prop pattern giving the consumer access to the open state and the required ref, ensuring maximum flexibility.
Step 2: Managing Focus and Keyboard Navigation (Roving Tabindex)
Instead of letting the browser’s native Tab key navigate through every single menu item, a proper menu component uses a roving tabindex. Only the currently active item has tabIndex={0}, while all other items have tabIndex={-1}. When the user presses the arrow keys, we shift the active index and programmatically focus the target element.
Here is how we implement the core hook logic for keyboard navigation:
export function usePopoverMenu() {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const openMenu = () => {
setIsOpen(true);
setActiveIndex(0); // Focus first item by default
};
const closeMenu = () => {
setIsOpen(false);
setActiveIndex(-1);
triggerRef.current?.focus(); // Return focus to trigger
};
const toggleMenu = () => {
if (isOpen) {
closeMenu();
} else {
openMenu();
}
};
return {
isOpen,
openMenu,
closeMenu,
toggleMenu,
activeIndex,
setActiveIndex,
triggerRef,
menuRef,
};
}
Handling Key Events inside the Menu
When the menu is open, we intercept keyboard events on the container to facilitate arrow key navigation, home/end navigation, and closing on Escape.
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>, totalItems: number) => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
setActiveIndex((prev) => (prev + 1) % totalItems);
break;
case 'ArrowUp':
event.preventDefault();
setActiveIndex((prev) => (prev - 1 + totalItems) % totalItems);
break;
case 'Home':
event.preventDefault();
setActiveIndex(0);
break;
case 'End':
event.preventDefault();
setActiveIndex(totalItems - 1);
break;
case 'Escape':
event.preventDefault();
closeMenu();
break;
case 'Tab':
// Trap focus or close on tab out
closeMenu();
break;
default:
break;
}
};
Step 3: Implementing Click-Outside and Escape Key Management
An accessible popover must disappear when a user interacts outside its bounds. We achieve this by listening for mousedown events on the document and checking whether the click target falls outside our menu and trigger refs.
useEffect({
if (!isOpen) return;
const handleOutsideClick = (event: MouseEvent) => {
const target = event.target as Node;
if (
menuRef.current &&
!menuRef.current.contains(target) &&
triggerRef.current &&
!triggerRef.current.contains(target)
) {
closeMenu();
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, [isOpen, closeMenu]);
Combining this with our Escape key handler ensures the component never traps the user unintentionally.
Step 4: Putting It All Together in the React Component
Now let’s assemble our full DropdownMenu component, integrating proper ARIA attributes (aria-haspopup="menu", aria-expanded, role="menu", role="menuitem").
import React, { Children, cloneElement, isValidElement } from 'react';
export const DropdownMenu: React.FC<PopoverMenuProps> = ({
trigger,
children,
align = 'start',
}) => {
const {
isOpen,
closeMenu,
toggleMenu,
activeIndex,
setActiveIndex,
triggerRef,
menuRef,
} = usePopoverMenu();
const childrenArray = Children.toArray(children);
const totalItems = childrenArray.length;
// Focus the active item whenever activeIndex changes
useEffect(() => {
if (isOpen && activeIndex >= 0 && menuRef.current) {
const items = menuRef.current.querySelectorAll<HTMLElement>('[role="menuitem"]');
items[activeIndex]?.focus();
}
}, [isOpen, activeIndex]);
return (
<div className="relative inline-block text-left">
{/* Trigger Button */}
{trigger(isOpen, triggerRef as React.RefObject<HTMLButtonElement>)}
{/* Popover Menu Container */}
{isOpen && (
<div
ref={menuRef}
role="menu"
aria-orientation="vertical"
tabIndex={-1}
onKeyDown={(e) => handleKeyDown(e, totalItems)}
className={`absolute z-50 mt-2 w-56 origin-top-${align} rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none`}
>
<div className="py-1">
{Children.map(children, (child, index) => {
if (!isValidElement(child)) return child;
return cloneElement(child, {
isFocused: index === activeIndex,
onMouseEnter: () => setActiveIndex(index),
onClick: (e: React.MouseEvent) => {
child.props.onClick?.(e);
closeMenu();
},
} as any);
})}
</div>
</div>
)}
</div>
);
};
interface MenuItemProps {
children: ReactNode;
onClick?: () => void;
isFocused?: boolean;
onMouseEnter?: () => void;
}
export const MenuItem: React.FC<MenuItemProps> = ({
children,
onClick,
isFocused,
onMouseEnter,
})
Wait, let’s complete the MenuItem component definition properly so it handles its own styling and tabIndex based on focus state:
export const MenuItem: React.FC<MenuItemProps> = ({
children,
onClick,
isFocused,
onMouseEnter,
}) => {
return (
<div
role="menuitem"
tabIndex={isFocused ? 0 : -1}
onMouseEnter={onMouseEnter}
onClick={onClick}
className={`w-full text-left px-4 py-2 text-sm cursor-pointer ${
isFocused ? 'bg-gray-100 text-gray-900 outline-none' : 'text-gray-700'
}`}
>
{children}
</div>
);
};
Step 5: Consuming the Component
Using our newly built accessible component is clean, intuitive, and guarantees compliance with modern accessibility standards out of the box:
export default function App() {
return (
<div className="p-8">
<DropdownMenu
trigger={(isOpen, triggerRef) => (
(
<button
ref={triggerRef}
onClick={toggleMenu}
aria-haspopup="menu"
aria-expanded={isOpen}
className="px-4 py-2 bg-blue-600 text-white rounded-md shadow hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Options Menu
</button>
)
)}
>
<MenuItem onClick={() => alert('Profile clicked')}>Your Profile</MenuItem>
<MenuItem onClick={() => alert('Settings clicked')}>Settings</MenuItem>
<MenuItem onClick={() => alert('Sign out clicked')}>Sign out</MenuItem>
</DropdownMenu>
</div>
);
}
Pro Tip: Always test your components using screen readers like VoiceOver (macOS) or NVDA (Windows), and verify that you can complete full workflows using only the keyboard (
Tab,Arrow keys,Escape,Enter).
Conclusion
Building custom UI components in React requires looking past the visual presentation and prioritizing accessibility from day one. By implementing roving tabindex, proper ARIA attributes, robust outside-click detection, and Escape key handling, you ensure that every user—regardless of whether they use a mouse, screen reader, or keyboard—has a seamless, frustration-free experience.