Skip to main content

Building an Accessible Dropdown Without a Library

A keyboard with illuminated keys in a dark environment
Apr 6, 20263 min readReact, Design Systems, ARIA

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-controls pointing to the menu
  • Menu: role="menu", aria-labelledby pointing back to the trigger
  • Items: role="menuitem" on each actionable item, role="separator" on dividers
  • Focus: Roving tabIndex: the active item gets 0, 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.