Build a custom dropdown component
Controlled-or-uncontrolled value; a trigger button with `aria-haspopup`/`aria-expanded` and a listbox of options with `role='option'`/`aria-selected`; keyboard support (ArrowUp/Down, Home/End, Enter, Esc, type-ahead); outside-click + Escape to close; focus management; portal for stacking; configurable rendering of items.
Building a dropdown looks easy and is actually a small accessibility test. The visible behavior is "click → list opens"; the invisible work is keyboard, ARIA, focus, and portal/stacking.
1. API — controlled and uncontrolled
<Dropdown
options={options} // [{ value, label }, ...]
value={value} // controlled
defaultValue={...} // uncontrolled
onChange={setValue}
renderOption={(opt) => ...} // customization
placeholder="Choose..."
/>2. Structure & ARIA
The listbox/option pattern:
<button
ref={triggerRef}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls="dropdown-list"
onClick={() => setOpen(o => !o)}
>
{selectedLabel || placeholder}
</button>
{open && (
<ul
id="dropdown-list"
role="listbox"
aria-activedescendant={`opt-${activeIndex}`}
tabIndex={-1}
ref={listRef}
>
{options.map((opt, i) => (
<li
key={opt.value}
id={`opt-${i}`}
role="option"
aria-selected={value === opt.value}
onMouseEnter={() => setActiveIndex(i)}
onClick={() => select(opt)}
>
{opt.label}
</li>
))}
</ul>
)}3. Keyboard
- ArrowDown/Up — move
activeIndex(open if closed). - Home/End — first / last.
- Enter / Space — select active option.
- Escape — close, return focus to trigger.
- Type-ahead — typing 'b' jumps to the first option starting with 'b' (with a short reset timeout).
- Tab — close and let focus move on.
4. Outside click + Escape
useEffect(() => {
if (!open) return;
const onDoc = (e) => { if (!containerRef.current.contains(e.target)) setOpen(false); };
const onKey = (e) => { if (e.key === "Escape") { setOpen(false); triggerRef.current?.focus(); } };
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
}, [open]);5. Focus
- Focus stays on the trigger while open; the listbox is operated via
aria-activedescendant— no need to focus options. - On Escape / select, return focus to the trigger.
- On open, set
activeIndexto the selected option (or 0).
6. Portal & positioning
For dropdowns inside scroll containers / modals, portal to <body> so the listbox isn't clipped by overflow:hidden. Position with Floating UI (@floating-ui/react) — handles collision, flip, scroll, and resize.
7. The polish
- Searchable / combobox variant (filter as you type).
- Multi-select with checkboxes + visible chips.
- Virtualization for very long lists.
- Loading state if options come from an API.
Interview framing
"The visible part is a trigger button toggling a list. The real work is the keyboard model and ARIA: aria-haspopup+aria-expanded on the trigger; role='listbox' with role='option' children and aria-selected; aria-activedescendant so focus stays on the trigger while arrow keys move the active option. Outside click and Escape close it (with focus return). Portal + Floating UI for positioning. Optionally controlled/uncontrolled value, type-ahead, multi-select, and search."
Follow-up questions
- •Why use aria-activedescendant instead of focusing each option?
- •Why portal to <body>?
- •How would you turn this into a combobox with search?
- •Controlled vs uncontrolled value — when do you support both?
Common mistakes
- •No keyboard support — Tab/Arrow/Esc broken.
- •No outside-click close.
- •Listbox clipped by parent overflow:hidden — no portal.
- •Focus not returned to trigger on close.
- •Hard-coded positioning that breaks at viewport edges.
Performance considerations
- •Render only when open (or keep mounted hidden if open frequently). Memoize options list. Virtualize for very long lists. Avoid re-creating handlers on every render if memoizing items.
Edge cases
- •Very long list — virtualize.
- •Disabled options — skip in arrow navigation.
- •Dropdown near viewport edge — flip.
- •Inside a modal — z-index/portal issues.
Real-world examples
- •GitHub repo picker, Notion property menus, Linear status menu.
- •Headless UI / Radix / Reach Listbox primitives.