ESC

Type to search the knowledge base.

Custom Dropdown Select

Machine-coding brief for a custom select — listbox pattern, keyboard typeahead, focus, and form-friendly API.

intermediate4 min read
  • machine-coding
  • interview
  • react
  • a11y

Problem statement

Build a custom dropdown select that looks designed but behaves like a native <select> for keyboard and screen reader users. This round fails when candidates style a div and forget listbox semantics, typeahead, or focus restore.

Requirements

Must have

  • Closed trigger shows current label or placeholder
  • Open list of options; click selects and closes
  • Keyboard: Enter/Space open; ↑/↓ move; Enter select; Escape close; Tab closes and moves on
  • Typeahead: printable characters jump to matching option
  • Controlled value + onChange
  • Disabled options + disabled whole control
  • Click outside closes

Should have

  • Portal or fixed positioning flip when near viewport edge
  • Search/filter input inside menu (combobox variant — clarify)
  • Grouped options (label headers)

Nice to have

  • Multi-select
  • Virtualized long lists
  • Form name via hidden input

Planning (5 minutes out loud)

  1. Pattern — APG Select-Only Combobox or Listbox on button+listbox
  2. Active vs selected — highlight index ≠ committed value while navigating
  3. Ids for aria-activedescendant or real focus on options
  4. MVP — open/close + mouse select + Escape; then arrows + typeahead
  5. Native select fallback discussion if time is tight

Architecture

Select
├── Trigger (button)
├── Listbox (ul/role=listbox)
│   └── Option (li/role=option)
└── useDismiss / useTypeahead

Data model

type Option = {
  value: string;
  label: string;
  disabled?: boolean;
};

type SelectProps = {
  options: Option[];
  value: string | null;
  onChange: (value: string) => void;
  placeholder?: string;
  disabled?: boolean;
  id?: string;
  "aria-label"?: string;
  "aria-labelledby"?: string;
};

Implementation sketch

Open state + outside click

function useOnClickOutside(
  ref: RefObject<HTMLElement | null>,
  handler: () => void,
  enabled: boolean
) {
  useEffect(() => {
    if (!enabled) return;
    function onPointer(e: MouseEvent) {
      if (!ref.current?.contains(e.target as Node)) handler();
    }
    document.addEventListener("mousedown", onPointer);
    return () => document.removeEventListener("mousedown", onPointer);
  }, [ref, handler, enabled]);
}

Core component (abridged)

function Select({
  options,
  value,
  onChange,
  placeholder = "Select…",
  disabled,
}: SelectProps) {
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(0);
  const rootRef = useRef<HTMLDivElement>(null);
  const listId = useId();
  const selected = options.find((o) => o.value === value);

  useOnClickOutside(rootRef, () => setOpen(false), open);

  useEffect(() => {
    if (!open) return;
    const idx = Math.max(
      0,
      options.findIndex((o) => o.value === value && !o.disabled)
    );
    setActive(idx === -1 ? 0 : idx);
  }, [open, value, options]);

  function commit(i: number) {
    const opt = options[i];
    if (!opt || opt.disabled) return;
    onChange(opt.value);
    setOpen(false);
  }

  function onTriggerKeyDown(e: React.KeyboardEvent) {
    if (disabled) return;
    if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      setOpen(true);
    }
  }

  function onListKeyDown(e: React.KeyboardEvent) {
    if (e.key === "Escape") {
      e.preventDefault();
      setOpen(false);
      return;
    }
    if (e.key === "ArrowDown") {
      e.preventDefault();
      setActive((i) => nextEnabled(options, i, 1));
    }
    if (e.key === "ArrowUp") {
      e.preventDefault();
      setActive((i) => nextEnabled(options, i, -1));
    }
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      commit(active);
    }
    if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
      const idx = typeaheadIndex(options, e.key, active);
      if (idx >= 0) setActive(idx);
    }
  }

  return (
    <div ref={rootRef} className="select">
      <button
        type="button"
        disabled={disabled}
        aria-haspopup="listbox"
        aria-expanded={open}
        aria-controls={listId}
        onClick={() => setOpen((o) => !o)}
        onKeyDown={onTriggerKeyDown}
      >
        {selected?.label ?? placeholder}
      </button>
      {open && (
        <ul
          id={listId}
          role="listbox"
          tabIndex={-1}
          aria-activedescendant={`${listId}-opt-${active}`}
          onKeyDown={onListKeyDown}
          ref={(n) => n?.focus()}
        >
          {options.map((opt, i) => (
            <li
              key={opt.value}
              id={`${listId}-opt-${i}`}
              role="option"
              aria-selected={opt.value === value}
              aria-disabled={opt.disabled || undefined}
              data-active={i === active || undefined}
              onMouseEnter={() => setActive(i)}
              onClick={() => commit(i)}
            >
              {opt.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Typeahead helper

function typeaheadIndex(options: Option[], ch: string, from: number) {
  const q = ch.toLowerCase();
  const n = options.length;
  for (let step = 1; step <= n; step++) {
    const i = (from + step) % n;
    if (!options[i].disabled && options[i].label.toLowerCase().startsWith(q)) {
      return i;
    }
  }
  return -1;
}

Buffer multi-char typeahead with a 500ms reset timer for production polish.

Accessibility essentials

Piece Role / state
Trigger button, aria-haspopup="listbox", aria-expanded
List role="listbox"
Option role="option", aria-selected
Active aria-activedescendant or focus management
Disabled option aria-disabled + skip in keyboard nav

Visible focus on active option. Ensure contrast on highlighted row.

Performance notes

  • Hundreds of options: OK; thousands: virtualize and keep aria-activedescendant in sync with scroll
  • Don’t rebuild option nodes from unfiltered server data on every keystroke without memo

Footguns

  1. Focus lost when list unmounts — return focus to trigger on close
  2. Space scrolls the page — preventDefault on keys you handle
  3. Mouse hover + keyboard fighting active index
  4. Forgetting disabled options in arrow navigation
  5. Opening upward off-screen without flip

Interview out-loud answer

Custom select is an accessible listbox attached to a button. I’d keep selected value controlled, track an active index while open, support arrows, typeahead, Escape, and outside click. Options skip disabled entries. MVP is mouse + Escape; keyboard and typeahead next. If design allows, I’d mention native select for mobile as a progressive enhancement.

Further reading