Back to Machine Coding
Machine Coding
easy
mid

How would you build a custom dropdown component in React?

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.

4 min read·~40 min to think through

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

jsx
<Dropdown
  options={options}                       // [{ value, label }, ...]
  value={value}                           // controlled
  defaultValue={...}                      // uncontrolled
  onChange={setValue}
  renderOption={(opt) => ...}             // customization
  placeholder="Choose..."
/>

2. Structure & ARIA

The listbox/option pattern:

jsx
<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

jsx
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 activeIndex to 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.

Senior engineer discussion

Seniors reach for accessibility-first primitives (Radix/Headless UI) or build one with full keyboard, ARIA, focus management, and portal + Floating UI — not a hand-rolled `absolute` div that breaks in containers and at viewport edges.

Related questions