DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

The dropdown menu that everyone builds wrong

Almost every app has one: the little "Actions" button, or the "⋯" overflow menu, that drops a list of commands. It looks trivial. It is not. The keyboard behaviour behind that button is one of the most under-built widgets on the web, and getting it right teaches you a pattern you'll reuse in tabs, toolbars, and radio groups forever.

So for Day 24 I built a real menu button from scratch — no library, about 120 lines of vanilla JS. Here's what actually goes into one.

First: is it even a menu?

Before writing a line, decide what the thing is. Three widgets look alike and mean completely different things:

  • A native <select> or an ARIA listbox is for picking a value from a set. The choice sticks.
  • A menu button is for firing a command — Rename, Duplicate, Delete. You invoke it and the menu closes. Nothing stays "selected".

If your rows are verbs, you want a menu button. If they're values you keep, you want a listbox. Choosing wrong confuses screen readers, because the two have different keyboard models.

The trigger is a handshake

The button is how the whole widget introduces itself to assistive tech:

<button aria-haspopup="true" aria-expanded="false" aria-controls="menu">
  Actions ▼
</button>
<ul id="menu" role="menu" hidden></ul>
Enter fullscreen mode Exit fullscreen mode

aria-haspopup="true" says "activating this opens a menu", so a screen reader announces "Actions, menu, collapsed" instead of a plain button. aria-expanded is the single source of truth for open state — your JS flips it every single time the menu opens or closes. aria-controls wires the button to the menu by id. Keep those three honest and the widget is legible to someone who never sees it.

Opening: which item gets focus?

This is a real spec rule, not a nicety. How you open decides where focus lands:

  • Click, Enter, Space, or ArrowDown → focus the first item.
  • ArrowUp → focus the last item.

That ArrowUp-to-last trick lets a keyboard user hit the bottom of a long menu in one keystroke. Funnel every path through one open(focusFirst) so the decision lives in one place.

The core: roving tabindex

Here's the part everyone skips. A menu has many focusable items, but Tab should treat the whole menu as one tab stop — Tab moves past it, it does not walk you through every row.

The technique is roving tabindex. Exactly one item carries tabindex="0" (reachable by Tab and by script). Every other item is tabindex="-1" (focusable only by script). As the arrow keys move the highlight, you "rove" that 0 from the old item to the new one and call .focus() on it:

function focusItem(i){
  const list = items();
  list.forEach(el => el.tabIndex = -1);  // clear all
  list[i].tabIndex = 0;                  // this one is tabbable
  list[i].focus();                       // move REAL focus onto it
  cur = i;
}
Enter fullscreen mode Exit fullscreen mode

So Tab enters and leaves the menu as a unit, while the arrows drive movement inside. This is the composite-widget pattern, and once you've built it here you'll recognise it in tabs, toolbars, radio groups and grids.

Why not aria-activedescendant?

If you read my Day 23 autocomplete post, you'll notice the combobox did the opposite — it kept DOM focus on the input and moved a virtual cursor with aria-activedescendant. A menu moves real focus onto each item instead. Why the difference?

The combobox needs you to keep typing while a highlight moves through suggestions, so focus stays in the text field. A menu has no text entry — the items themselves are the targets. Moving real focus is simpler, gives you native :focus styling for free, and lets each item behave like a real button.

Arrow, Home, End, and typeahead

Once focus roves, navigation is index math. ArrowDown is cur+1, ArrowUp is cur-1, and I wrap with ((i % n) + n) % n so the ends loop cleanly (the double-modulo handles negative indices). Home jumps to 0, End to the last item. preventDefault() on all of them so the page doesn't scroll under the open menu.

Then there's typeahead, which native <select> gives you for free and custom menus usually forget: press a letter and jump to the next matching item. Keep a small buffer that resets after ~500ms, and search from the item after the current one so repeated presses of the same letter cycle through matches. Type "d" twice → Duplicate, then Delete.

Activate, then close, then return focus

Enter, Space, and a click all funnel through one activate(i), so mouse and keyboard behave identically. Because these are commands, activating runs the action and closes the menu. Guard disabled items so a keyboard user can't fire a greyed-out command.

The most-missed detail in hand-rolled menus: return focus to the button. Esc closes and re-focuses the trigger, so the user isn't dumped at the top of the page. An outside click closes without grabbing focus. A focusout that leaves the widget (Tab, or clicking elsewhere) closes it too. One close(returnFocus) flag picks the right behaviour for each case.

Get one level of this right and submenus are just the same widget nested inside itself — ArrowRight opens a child, ArrowLeft returns to the parent.

This is exactly what Radix Menu and Headless UI do under the hood. There's a live LOOK / UNDERSTAND / BUILD walkthrough here — open it and drive the whole thing from the keyboard:

https://dev48v.infy.uk/design/day24-dropdown-menu.html

Top comments (0)