Dropdown Menu in React: Accessible, Animated, Keyboard-Ready
Build a fully accessible, animated React dropdown menu with keyboard navigation, ARIA roles, and smooth CSS transitions — without sacrificing DX or bundle size.
Why Most React Dropdowns Fail Accessibility
Here's the thing: you've probably shipped a dropdown that works fine with a mouse and breaks the moment someone reaches for their keyboard. It's one of the most common accessibility gaps in React apps, and it's almost always invisible in QA because nobody tests with Tab and Enter.
The WAI-ARIA spec for menus is actually pretty specific. You need role="menu" on the list, role="menuitem" on each option, aria-haspopup="true" and aria-expanded on the trigger, and focus management that traps inside the menu while it's open. That's a lot of moving parts to wire up by hand.
Honestly, most tutorials just slap a useState toggle on a <div> and call it a day. That gets you 60% of the way there visually, but screen reader users will be left in the dark — no announcement when it opens, no way to navigate with arrow keys, no Escape key to close.
The good news: you don't have to implement all of this from scratch. But you should understand what's happening under the hood, especially if you're building design-system components that need to stay consistent across an entire product.
The Minimal Accessible Dropdown: What You Actually Need
Let's start with a plain React implementation before reaching for any library. Four things are non-negotiable: focus management, keyboard event handling, click-outside dismissal, and proper ARIA attributes.
Here's a stripped-down implementation you can adapt. It handles Tab, Escape, ArrowUp, ArrowDown, and Home/End keys — which covers everything the ARIA authoring practices spec requires:
import { useRef, useState, useEffect, useCallback } from 'react';
const KEYS = { ESC: 'Escape', UP: 'ArrowUp', DOWN: 'ArrowDown', HOME: 'Home', END: 'End', ENTER: 'Enter' };
export function DropdownMenu({ trigger, items }) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const menuRef = useRef(null);
const triggerRef = useRef(null);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (!menuRef.current?.contains(e.target) && !triggerRef.current?.contains(e.target)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// Move focus to first item when menu opens
useEffect(() => {
if (open) {
setActiveIndex(0);
menuRef.current?.querySelector('[role="menuitem"]')?.focus();
}
}, [open]);
const handleKeyDown = useCallback((e) => {
if (!open) { if (e.key === KEYS.DOWN || e.key === KEYS.ENTER) { setOpen(true); } return; }
switch (e.key) {
case KEYS.ESC: setOpen(false); triggerRef.current?.focus(); break;
case KEYS.DOWN: e.preventDefault(); setActiveIndex(i => Math.min(i + 1, items.length - 1)); break;
case KEYS.UP: e.preventDefault(); setActiveIndex(i => Math.max(i - 1, 0)); break;
case KEYS.HOME: e.preventDefault(); setActiveIndex(0); break;
case KEYS.END: e.preventDefault(); setActiveIndex(items.length - 1); break;
}
}, [open, items.length]);
useEffect(() => {
if (!open) return;
const menuItems = menuRef.current?.querySelectorAll('[role="menuitem"]');
menuItems?.[activeIndex]?.focus();
}, [activeIndex, open]);
return (
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
ref={triggerRef}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen(v => !v)}
onKeyDown={handleKeyDown}
>
{trigger}
</button>
{open && (
<ul
ref={menuRef}
role="menu"
style={{ position: 'absolute', top: '100%', left: 0, listStyle: 'none', margin: 0, padding: '4px 0', background: '#fff', border: '1px solid #e2e8f0', borderRadius: '8px', minWidth: '180px', boxShadow: '0 4px 24px rgba(0,0,0,0.10)', zIndex: 50 }}
>
{items.map((item, i) => (
<li key={item.label} role="menuitem" tabIndex={activeIndex === i ? 0 : -1} onKeyDown={handleKeyDown} onClick={() => { item.onClick?.(); setOpen(false); }} style={{ padding: '8px 16px', cursor: 'pointer', outline: 'none', background: activeIndex === i ? '#f1f5f9' : 'transparent' }}>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}Worth noting: the tabIndex={activeIndex === i ? 0 : -1} pattern is called the roving tabindex technique. It means only the currently focused item is in the tab order, which is exactly what the ARIA spec calls for.
Adding CSS Animations Without Janking the Accessibility
Animation is where things get fun. A dropdown that just pops in feels cheap — you want a 150–200ms fade-and-slide that feels intentional. But CSS transitions don't play nicely with conditional rendering by default, because React removes the DOM node immediately when you toggle state.
The cleanest approach in 2026 is the CSS @starting-style rule, which landed in Chrome 117 and is now at about 92% global support. It lets you define the initial state of an element the first time it enters the DOM, so you get enter animations without any JS gymnastics:
/* dropdown.css */
[role="menu"] {
opacity: 1;
transform: translateY(0) scale(1);
transition: opacity 160ms ease, transform 160ms ease;
transform-origin: top left;
}
@starting-style {
[role="menu"] {
opacity: 0;
transform: translateY(-8px) scale(0.97);
}
}For exit animations you still need a workaround — either a CSS-only trick using display: none with transition-behavior: allow-discrete, or a lightweight library like Framer Motion's AnimatePresence. In practice, for a dropdown, an exit animation isn't worth the complexity. Users are already moving their cursor to the selected item.
One more thing — always respect prefers-reduced-motion. Wrap your transitions in a media query or check the preference in JS. People with vestibular disorders genuinely need this.
Radix UI DropdownMenu: When to Reach for a Library
Honestly, if you're building anything beyond a one-off demo, just use Radix UI's DropdownMenu primitive. It ships with all the accessibility wiring done correctly, it's unstyled, and it's fully tree-shakeable. You own the CSS completely.
Radix @radix-ui/react-dropdown-menu version 2.x handles focus trapping, portal rendering (so z-index wars become someone else's problem), sub-menus, radio groups, and checkboxes — all with the correct ARIA semantics out of the box.
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import './dropdown.css';
export function UserMenu() {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="trigger">Account</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="menu-content" sideOffset={5} align="end">
<DropdownMenu.Item className="menu-item" onSelect={() => console.log('profile')}>
Profile
</DropdownMenu.Item>
<DropdownMenu.Item className="menu-item" onSelect={() => console.log('settings')}>
Settings
</DropdownMenu.Item>
<DropdownMenu.Separator className="menu-sep" />
<DropdownMenu.Item className="menu-item menu-item--danger" onSelect={() => console.log('logout')}>
Log out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}That's it. You get correct keyboard behavior, Escape handling, click-outside, and ARIA attributes — no additional code. Style menu-content and menu-item however you want. If you're theming with glassmorphism or any of the style systems in Empire UI, Radix is an easy integration because it never ships its own CSS.
Styling Approaches: CSS Modules, Tailwind, and Inline Styles
There's no wrong answer here, but each approach has real tradeoffs that'll bite you in certain situations.
CSS Modules give you scoped class names and zero runtime cost. The downside is you lose Tailwind's composability and you end up managing a separate file per component. For dropdown menus specifically, that's usually fine — the component isn't that dynamic.
With Tailwind, you'd use the group and peer modifiers for open/closed state via data-state="open" attributes that Radix sets automatically:
// Tailwind + Radix — data-state targeting
<DropdownMenu.Content
className="bg-white rounded-lg shadow-lg border border-slate-200 p-1 min-w-[180px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
>That's a bit verbose but it works. Quick aside: Tailwind's built-in animate-in / animate-out utilities are a thin wrapper over CSS custom properties, so they're performant — no JS animation loop. If you want something more polished with consistent motion design across components, browse the components on Empire UI to see how different style systems handle interactive states.
Testing Your Dropdown: What Most Devs Skip
Manual keyboard testing takes 30 seconds and catches 80% of the issues. Open your app, click away from the dropdown so it's closed, then Tab to the trigger, press Enter to open, navigate with arrow keys, press Escape, and confirm focus returns to the trigger. That flow alone will surface most regressions.
For automated tests, React Testing Library is your friend. Don't test implementation details — test what a user would do:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserMenu } from './UserMenu';
test('opens on click, closes on Escape, returns focus to trigger', async () => {
const user = userEvent.setup();
render(<UserMenu />);
const trigger = screen.getByRole('button', { name: /account/i });
await user.click(trigger);
expect(screen.getByRole('menu')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});That test is worth more than a dozen snapshot tests. It tells you the component behaves correctly for real users, not just that its DOM structure hasn't changed.
FAQ
No — you can build one with vanilla React hooks as long as you handle roving tabindex, Escape key, and the correct ARIA attributes. Radix just saves you from doing that work yourself and from making subtle mistakes.
For enter animations, use CSS @starting-style (supported in all modern browsers since late 2023). For exit animations, you need either transition-behavior: allow-discrete with display: none, or a library like Framer Motion's AnimatePresence.
At minimum: aria-haspopup="menu" and aria-expanded={open}. The menu list itself needs role="menu" and each item needs role="menuitem".
It's a stacking context issue — usually caused by a parent with transform, filter, or will-change. Use a React portal (or Radix's built-in portal) to render the menu at the document body level, which sidesteps the problem entirely.