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:
- Enter / Space opens it from the trigger.
- Focus moves into the options (so arrow keys do something useful).
- Arrow keys move between options.
- Enter / Space selects the active option and closes.
- Escape closes without selecting.
- Tab leaves (and we close to avoid orphaned popups). No trap. Ever.
- 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:
- When it opens, focus goes into the list (in this model: onto the active option).
- 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-expandedreflects open/closed state - [ ] List uses
role="listbox"and options userole="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.