The default move
When a dropdown needs to be accessible, the reflex is to reach for Radix or Headless UI, and they're genuinely good libraries; but if your component library already has a custom Dropdown that works, is styled, and is used everywhere, pulling in a whole new dependency just for some ARIA attributes feels backwards.
I had that exact Dropdown. It opened on click, closed on outside click, and
rendered items, and that was the whole story: no ARIA roles, no keyboard
navigation, no focus management. A screen reader saw a <button> that
spawned some <div>s and shrugged.
What the spec actually requires
The good news is the WAI-ARIA Menu Pattern spells out a clear contract:
- Trigger:
aria-haspopup="true",aria-expanded,aria-controlspointing to the menu - Menu:
role="menu",aria-labelledbypointing back to the trigger - Items:
role="menuitem"on each actionable item,role="separator"on dividers - Focus: Roving
tabIndex: the active item gets0, everything else gets-1 - Keyboard: Arrow keys to navigate, Enter/Space to activate, Escape to close, Home/End to jump
That's the whole thing, and it's prescriptive enough that the implementation almost writes itself.
Roving tabIndex
The key pattern is roving tabIndex: instead of moving DOM focus around with
document.activeElement tricks, each item just declares whether it's the
current focus target.
<button
role='menuitem'
tabIndex={i === activeIndex ? 0 : -1}
ref={el => {
itemRefs.current[i] = el;
}}
>
{item.label}
</button>When the active index changes, I call .focus() on the new target:
const focusItem = useCallback((index: number) => {
setActiveIndex(index);
itemRefs.current[index]?.focus();
}, []);This gives a screen reader a clear signal about which item is current, and Tab moves focus out of the menu entirely, which is exactly what the spec wants.
Skipping non-actionable items
Dropdown items aren't always clickable; dividers and disabled items need to be skipped during keyboard navigation. So I pre-compute the actionable indices once:
const actionableItems = useMemo(
() =>
items.reduce<number[]>((acc, item, i) => {
if (!item.divider && !item.disabled) acc.push(i);
return acc;
}, []),
[items]
);Arrow key handlers index into this array, so pressing Down from the last actionable item wraps back to the first, and any disabled items in between are invisible to the keyboard.
The full keyboard contract
The handleMenuKeyDown switch covers every interaction the spec demands:
case 'ArrowDown': {
const nextIdx = currentActionableIndex < actionableItems.length - 1
? actionableItems[currentActionableIndex + 1]
: actionableItems[0]; // wrap
if (nextIdx != null) focusItem(nextIdx);
break;
}
case 'Escape': { closeMenu(); break; }
case 'Enter':
case ' ': {
// Activate the focused item
item.onClick?.();
closeMenu();
break;
}
case 'Tab': { closeMenu(); break; }Escape and Tab both close the menu, but they differ on where focus lands:
Escape returns focus to the trigger (triggerRef.current?.focus()), while
Tab lets the browser move focus naturally to the next element in the
document.
ID linkage with useId
React 19's useId() generates stable, SSR-safe identifiers for the
trigger-to-menu relationship:
const uid = useId();
const menuId = `dropdown-menu-${uid}`;
const triggerId = `dropdown-trigger-${uid}`;The trigger gets aria-controls={menuId}, the menu gets
aria-labelledby={triggerId}, and there's no prop drilling and no collision
risk across multiple Dropdowns on the same page.
Testing the invisible
Accessibility features are invisible to sighted users, which is exactly why testing isn't optional here. I added 22 tests covering:
- ARIA attributes render correctly on trigger, menu, and items
- Arrow keys cycle through actionable items and wrap
- Disabled and divider items are skipped
- Enter and Space activate the focused item
- Escape closes and returns focus to trigger
- Focus moves to the first actionable item on open
The test count went from 10 to 32, the component gained ~120 lines, and I added zero new dependencies.
The takeaway
The ARIA menu spec is specific enough to follow mechanically: useId for
linkage, useMemo for the actionable indices, useCallback for focus
management, and a switch statement for keyboard events. If your design
system already owns the Dropdown, it already owns the accessibility too;
the only thing left is to sit down and write it.
