Problem RestatementProblem
Roblox asked a frontend system design question: design a reusable, production-grade dropdown menu (select) for a design system used by many teams. It should be easy to use, flexible, accessible (keyboard and screen readers), fast with many options, and consistent across the product.
RequirementsRequirements
- Single select and multi-select.
- Options from a static list or loaded asynchronously (search as you type).
- Optional search/filter box, groups and disabled options, and custom option rendering (icon + label).
- Keyboard: open/close, arrow navigation, Enter to select, Esc to close, type-ahead jump.
- Accessible to screen readers. Works on mobile.
- Handles 10,000+ options smoothly.
Public API (React)
<Select
options={options} // [{ value, label, disabled?, group? }]
value={value} // controlled (optional)
defaultValue={initial} // uncontrolled (optional)
onChange={(v) => setValue(v)}
multiple={false}
searchable
loadOptions={(query) => fetchUsers(query)} // async (optional)
renderOption={(opt, state) => <UserRow {...opt} active={state.active} />}
placeholder="Select a user"
disabled={false}
aria-label="Assignee"
/>- Controlled vs uncontrolled: if
valueis passed, the parent owns the state (controlled). Otherwise the component keeps its own (uncontrolled, usingdefaultValue). Supporting both is standard for design systems. - Headless core + styled wrapper: a
useSelect()hook contains all logic (state, keyboard, ARIA props), and the<Select>component only renders. Teams with unusual UIs can use the hook directly.
Internal Structure
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
HOOK["useSelect hook - state machine"] --> TRIG["Trigger button / input"]
HOOK --> LIST["Listbox - rendered in a portal"]
LIST --> VIRT["Virtualized option rows"]
HOOK --> ASYNC["Async loader - debounce, cancel stale"]
POS["Positioning - flip / shift near edges"] --> LIST- State:
isOpen,highlightedIndex,selected,query,loading. Handle transitions as a small state machine (closed → open → selecting → closed). - Portal + positioning: render the list in a portal attached to
bodyso it isn't clipped byoverflow: hiddenparents. Position it next to the trigger, and flip above when there's no room below (a library like Floating UI). - Async options: debounce input (~200 ms), cancel older requests (AbortController) so results don't arrive out of order, and show loading and "no results" states.
Deep Dive — Making the dropdown usable without a mouseDeep dive
A design system component is used by dozens of teams and inherits whatever accessibility it has. Getting this wrong once propagates everywhere.
A styled div with a click handler
A <div> for the trigger, a <div> list that appears on click, options as <div>s.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
D["div trigger + div list"] --> TAB["Not in the tab order - keyboard users cannot reach it"]
D --> SR["Screen reader announces nothing - no role, no state"]
D --> ARROW["Arrow keys scroll the page instead of moving selection"]
D --> ESC["Escape does nothing - no way out without a mouse"]Visually it is a dropdown; to the accessibility tree it is a box of text. Anyone navigating by keyboard or screen reader cannot operate it at all, which in many organisations makes it unshippable regardless of how it looks.
Use a native <select>
The browser provides focus, keyboard handling, type-ahead and screen-reader semantics for free, and the mobile picker is the platform's own.
For a plain list of options this is genuinely the right answer, and worth saying so. Its limit is styling and content: <option> cannot contain markup, so no icons, no two-line options, no avatars, no async search — which is exactly what a design system dropdown is asked for. Once you need those, <select> is no longer an option, and everything it provided has to be rebuilt deliberately.
Implement the ARIA combobox pattern properly
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
T["Trigger: role=combobox, aria-expanded, aria-controls"] --> L["List: role=listbox"]
L --> O["Options: role=option, aria-selected, aria-disabled"]
T --> AD["aria-activedescendant points at the highlighted option"]
AD --> FOCUS["DOM focus stays on the trigger - only the pointer moves"]
K["Arrow keys / Home / End"] --> AD
K2["Enter or Space selects, Escape closes"] --> RET["Focus returns to the trigger"]
K3["Typing letters - type-ahead"] --> AD
L --> ANN["Announce result counts - '12 results'"]aria-activedescendant, not moving focus. DOM focus stays on the trigger while the highlighted option changes. Moving real focus into the list breaks typing in a combobox and makes the return path fragile.- The full keyboard map, because users expect the native one: arrows to move, Home and End to jump, Enter or Space to select, Escape to close, and letter keys for type-ahead.
- Return focus to the trigger on close. Leaving focus on a removed element sends the user back to the top of the document — one of the most common bugs in hand-built dropdowns.
- Announce state changes, including the result count when an async list loads, or a screen reader user has no idea the list changed.
Test it the way it will be used: tab to it, operate it entirely by keyboard with the screen reader on, and check that every state the eye can see has a corresponding announced state. Automated checks catch missing roles; they do not catch a focus trap.
PerformanceScale
- Virtualization: only render the ~15 visible rows (plus a buffer) for large lists, using fixed row heights to make scroll math cheap.
- Memoize filtered results, and avoid re-rendering all options on each highlight change (pass the index, and let rows re-render only when their own state changes).
- Lazy-load heavy option content (avatars).
Quality
- Tests: unit tests for the hook's state machine, interaction tests (keyboard), and automated accessibility checks (axe).
- Theming through design tokens (CSS variables), with no hard-coded colors.
- Semantic versioning for the component, a changelog, and deprecation warnings for renamed props.
Wrap-UpWrap-up
Expose a simple <Select> API that supports both controlled and uncontrolled use, single or multi-select, search, async loading and custom rendering, built on a headless useSelect hook with a clear state machine. Render the list in a positioned portal, follow the ARIA combobox/listbox pattern with proper focus and keyboard behavior, virtualize long lists, debounce and cancel async searches, and ship it with tests, theming tokens and versioning.