Keyboard-Navigable Dropdown

A dropdown is a tiny component with a huge talent: humiliating you in front of a keyboard user.

This guide shows the boring, reliable version: arrow keys work, Enter selects, Escape exits, Tab is never trapped, and screen readers actually know what opened. This is the simple “button opens listbox” pattern (not a searchable combobox).

Live demo: Keyboard-navigable dropdown

Open with Enter, move with / , select with Enter, close with Esc. No mouse required (but it still works).

Semantics: button + role="listbox" + role="option" — the simple “button opens listbox” pattern (not a searchable combobox), with roving focus and explicit Escape/Tab behaviour.


Why dropdowns fail in production (even when they “look fine”)

If you only click with a mouse, almost any dropdown seems fine. The problems show up when you use it like real people do:

  • Keyboard only (laptop on a train, injury, preference, or just “I like speed”)
  • Screen readers (no visual sense of “the thing opened”)
  • Mixed input (mouse and keyboard) during a workflow

Common broken behaviours I’ve seen shipped:

  • It opens, but focus stays on the page behind it (so the next Tab goes somewhere invisible).
  • Arrow keys scroll the page instead of navigating options (because you forgot to prevent default).
  • Escape does nothing (so the only “close” is the mouse).
  • Tab gets trapped inside (congrats, you built a keyboard prison).
  • Screen readers get a silent list of divs with no role, no state, and no idea what just happened.

From a WCAG angle, you’re usually poking at:

  • 2.1.1 Keyboard / 2.1.2 No Keyboard Trap
  • 2.4.3 Focus Order / 2.4.7 Focus Visible
  • 4.1.2 Name, Role, Value

Or in plain language:

“If you steal navigation, you must give predictable navigation back.”


The behaviour you actually want (no ARIA poetry)

When the user interacts with the dropdown:

  1. Enter / Space opens it from the trigger.
  2. Focus moves into the options (so arrow keys do something useful).
  3. Arrow keys move between options.
  4. Enter / Space selects the active option and closes.
  5. Escape closes without selecting.
  6. Tab leaves (and we close to avoid orphaned popups). No trap. Ever.
  7. Mouse still works, but keyboard is the contract.

If you can’t do that reliably, don’t ship a dropdown. Ship a <select> and go outside.


Step 1: Use real semantics (button + listbox + options)

You have two solid routes:

  • Native: just use <select> (it’s already accessible and boring).
  • Custom: if you must be custom, you need roles and state.

For a simple “button opens a list of options” pattern:

<button
  id="fruit-button"
  aria-haspopup="listbox"
  aria-expanded={open}
  aria-controls="fruit-listbox"
>
  Choose a fruit
</button>

{open && (
  <ul id="fruit-listbox" role="listbox" aria-labelledby="fruit-button">
    <li role="option" aria-selected="false">Apple</li>
    <li role="option" aria-selected="true">Banana</li>
  </ul>
)}

Why this matters:

  • The trigger says “I open something”.
  • The list says “I am a listbox”.
  • Each item says “I am an option” and whether it is selected.

That’s Name, Role, Value — not vibes.


Step 2: Make the keyboard model boring and explicit

Your keyboard handler should be intentionally boring:

  • Prevent default where needed (arrow keys, Enter inside listbox)
  • Handle Escape
  • Close on Tab (but don’t prevent Tab)

There are two legitimate focus models:

  • Model A (simple): move focus onto the option elements (roving tabIndex) ✅
  • Model B: keep focus on the listbox and use aria-activedescendant

The demo on this page uses Model A (roving tabIndex) because it’s straightforward and hard to mess up.

// Each option is focusable only when active
<li tabIndex={active ? 0 : -1} role="option" />

Then your handler becomes predictable:

const onListboxKeyDown = (e: React.KeyboardEvent) => {
  if (e.key === "Escape") close();
  if (e.key === "ArrowDown") { e.preventDefault(); move(1); }
  if (e.key === "ArrowUp") { e.preventDefault(); move(-1); }
  if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectActive(); }
  if (e.key === "Tab") close(); // don't prevent default
};

This is not fancy. It is reliable.


Step 3: Focus management (the thing everyone “forgets”)

The most important two focus rules:

  1. When it opens, focus goes into the list (in this model: onto the active option).
  2. When it closes, focus returns to the trigger.

In React terms:

useEffect(() => {
  if (open) focusActiveOption();
  else triggerRef.current?.focus();
}, [open]);

If you don’t do this, users tab into the void and your dropdown becomes haunted.


Step 4: Mouse support is allowed — it’s just not the contract

Mouse behaviour is straightforward:

  • Click trigger toggles open
  • Click option selects
  • Click outside closes

The rule is: mouse should work without breaking the keyboard model.


How to GS-TDD this dropdown (yes, even UI widgets)

You don’t test this by screenshotting it and whispering “seems fine”.

Start with behaviour:

it("opens with Enter, navigates with arrows, selects with Enter, closes with Escape", async () => {
  render(<KeyboardNavigableDropdownDemo />);

  const trigger = screen.getByRole("button", { name: /choose a fruit/i });
  trigger.focus();

  await userEvent.keyboard("{Enter}");
  expect(await screen.findByRole("listbox")).toBeInTheDocument();

  await userEvent.keyboard("{ArrowDown}");
  expect(screen.getByRole("option", { name: /banana/i })).toHaveFocus();

  await userEvent.keyboard("{Enter}");
  expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
  expect(screen.getByRole("button", { name: /banana/i })).toBeInTheDocument();
});

Gold implementation: make it pass in the simplest boring way.

Refactor: once it’s green, extract a small primitive (<Dropdown> or useListbox) so the next team doesn’t reinvent a keyboard trap.


Common mistakes to watch for

If you only remember one checklist, make it this:

  • Focus stays behind the dropdown when it opens
  • No roles/state (screen readers get silence)
  • Escape doesn’t close
  • Arrow keys scroll the page
  • Tab is trapped inside the options
  • Focus doesn’t return to the trigger after close

These are not “edge cases”. They are the entire point of the component.


A tiny checklist

Before you ship:

  • [ ] Trigger is a real button
  • [ ] aria-expanded reflects open/closed state
  • [ ] List uses role="listbox" and options use role="option"
  • [ ] Arrow keys move the active option
  • [ ] Enter selects
  • [ ] Escape closes
  • [ ] Tab leaves (no trap)
  • [ ] Focus returns to trigger on close
  • [ ] At least one test covers open → navigate → select → close

If you can tick these without crossing your fingers, you shipped a dropdown that behaves like a grown-up.