Custom Dropdown Select
Machine-coding brief for a custom select — listbox pattern, keyboard typeahead, focus, and form-friendly API.
- 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 (
labelheaders)
Nice to have
- Multi-select
- Virtualized long lists
- Form
namevia hidden input
Planning (5 minutes out loud)
- Pattern — APG Select-Only Combobox or Listbox on button+listbox
- Active vs selected — highlight index ≠ committed value while navigating
- Ids for
aria-activedescendantor real focus on options - MVP — open/close + mouse select + Escape; then arrows + typeahead
- 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-activedescendantin sync with scroll - Don’t rebuild option nodes from unfiltered server data on every keystroke without memo
Footguns
- Focus lost when list unmounts — return focus to trigger on close
- Space scrolls the page — preventDefault on keys you handle
- Mouse hover + keyboard fighting active index
- Forgetting disabled options in arrow navigation
- 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.
Related on this site
- Autocomplete Search Box
- Multi Select Chips Input
- Accessible Modal Dialog
- Machine Coding Interview Framework