Design a custom select dropdown
A design-system select that picks a value, and its cousin the menu button that runs an action. A button and a list look easy; the hard parts are choosing the right pattern, the ARIA roles, a full keyboard model with typeahead, placing the popup near screen edges and inside scrolling containers, long and async option lists, phones, and plain HTML forms.
The question
Design a select dropdown for our design system. Teams use it for short lists like "Sort by", long lists like 250 countries, lists with groups and disabled options, and sometimes picking several values at once.
Product also wants the "three dots" actions menu built from the same parts. Walk me through when you'd use the native element, what the API looks like, how it works with a keyboard and a screen reader, and how the popup stays visible near the edge of the screen.
Real-life examples: The country picker on a Stripe checkout form, GitHub's "Sort by" and branch menus, The "More actions" menu on a Gmail message, Linear's status and priority pickers, The time zone select in Google Calendar settings
Requirements: what to ask before drawing anything
A select is a control that shows one chosen value and, when you open it, a list of other values to pick from. The browser has one built in: the select element. Design systems still build their own, mostly because the native one can't be styled the way designers want, can't show icons or two lines of text per option, and looks different on every operating system.
A menu button looks almost the same: a button that opens a list. But the list holds actions like "Rename", "Duplicate", or "Delete", not values. Choosing one runs a command and nothing stays selected. Interviewers often ask for "a dropdown" and mean either one, so the first useful thing you can do is ask which.
This guide designs both, because they share most of the parts: a trigger, a popup that floats next to it, a list you move through with the keyboard, and rules for closing. They differ in roles, in where focus goes, and in what Enter does. Getting that difference right is a strong senior signal.
Start by asking questions. Each answer below changes the design. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| Pick a value, or run an action? | Both: a select for form fields and filters, and an actions menu for rows and toolbars. | Two ARIA patterns (listbox and menu) on one shared engine for the popup and the keyboard. |
| How many options? | Usually 3 to 30. The worst case is about 250 countries or 600 time zones. One team has a list of 5,000 product SKUs. | Typeahead matters at 30 and up. Above roughly 300 we render only the visible rows. |
| Where do options come from? | Most are passed in as props. Some load from an API when the list opens. | Loading, empty, and error states inside the popup, and cancelling stale requests. |
| One value or many? | One by default. Some filters need several ("Status: Open, In review"). | Selection state becomes a set, the trigger shows a summary, and the popup stays open between picks. |
| Can the user type to filter? | No. Typing jumps to a matching option. A filter text box is the autocomplete component, which already exists. | We build a select-only control, not an editable combobox. This keeps the scope honest. |
| Which devices? | Last two versions of Chrome, Edge, Firefox, Safari, plus iOS Safari and Chrome on Android. About 45% of sessions are on phones. | On touch screens the native picker is often the better choice. |
| Does it sit inside forms? | Yes: settings pages and checkout. Forms are plain HTML posts in some older pages. | The value must reach FormData with a name, and required fields must block submit. |
| Accessibility target? | WCAG 2.2 AA, following the WAI-ARIA Authoring Practices (APG) patterns. | Correct roles, names, states, and a full keyboard model, tested with real screen readers. |
Functional requirements
- Show the current value on a trigger button, or a placeholder like "Choose a country" when nothing is chosen.
- Open a popup with the options on click, tap, or key press, and close it on a pick, Escape, a click outside, or Tab.
- Support option groups with headings, disabled options, options with an icon and a second line of text, and an empty state.
- Single select by default, multi-select as an opt-in mode.
- Options passed in directly or loaded on open from an async function.
- An actions menu variant: items run a callback, and some items can be links or open a sub-menu (sub-menus are a stretch goal).
- Work as a form field: a
name, a submitted value,required, and a disabled state.
Non-functional requirements
- Accessible: every action works with the keyboard alone, and a screen reader announces the name, the current value, the open or closed state, and each option as you move.
- Fast: the popup opens in under 100 ms after a click on a mid-range phone, even with 250 options. Arrow key moves feel instant (under one frame, about 16 ms).
- Always visible: the popup never opens off screen and is never cut off by a parent with
overflow: hidden. - Small: the core under about 6 KB compressed, since it ships on almost every page. The virtualized list loads only when a list is long.
- Works in every layout: inside dialogs, inside scrolling panels, in right-to-left languages, and at 200% zoom.
Out of scope, and why
- Typing to filter: that's an editable combobox, covered in the autocomplete guide. We reuse its ideas but don't rebuild it.
- Creating new options ("Add a new tag"): that's a tag input.
- Menu bars like a desktop app's File and Edit menus: a different pattern with left and right arrow keys between menus.
- Context menus opened by right click: same menu list, different trigger and positioning (at the pointer). Worth one sentence in the interview, not a chapter.
Check your understanding · Exercise 1
Which answers would change the design of this component in a real way? Select all that apply.
What makes a dropdown hard
A junior answer draws a button, a div with a list, and a useState(false) for open. That version works with a mouse on a big desktop screen. Then it meets real users, and five things break it. Every later chapter answers one of them.
1. It is two components that look like one
A select holds state: a value that persists and gets submitted. A menu holds commands: it runs one and forgets. Screen readers announce them differently ("Country, combo box, Japan, collapsed" versus "Actions, menu button, collapsed"), and the keyboard behaves differently: in a select, focus usually stays on the trigger while you move through options; in a menu, focus moves into the list. Use the wrong pattern and a screen reader user hears a lie about what will happen when they press Enter.
2. The keyboard model is big
Users expect the select to behave like the native one they've used for 25 years. That means arrow keys, Home and End, Page Up and Page Down, Enter and Space, Escape, Tab, Alt plus arrows, and typeahead: typing "un" quickly jumps to "United Kingdom". Each key has a different meaning when the list is closed versus open. A hand-built select that only handles arrows feels broken to every keyboard user within a second.
3. The popup floats over a page it doesn't control
The popup must appear next to the trigger, but the trigger can sit anywhere: at the bottom of the screen, inside a card with overflow: hidden, inside a scrolling side panel, inside a modal dialog, or inside a parent with transform, which changes how position: fixed behaves.
The popup has to flip above when there's no room below, slide sideways near a screen edge, follow the trigger when the page scrolls, and sit above everything else. The browser now helps a lot here (the top layer, the Popover API, CSS anchor positioning), but anchor positioning only reached every major engine in 2026, so older browsers still need a fallback.
4. Lists range from 3 items to thousands
The same component serves "Sort by: Newest, Oldest" and a 5,000-row SKU list that loads from a server. A long list needs virtualization (rendering only the rows you can see), which fights with ARIA, because a screen reader can only point at options that exist in the DOM. An async list needs loading, empty, error, and retry states inside a popup that's already open.
5. It competes with a native control that's very good
The native select already does all of the above, free, with perfect accessibility, a phone-friendly picker on touch devices, and full form support. Every custom select is a trade: you get styling and rich options, you pay with code, bugs, and testing. A senior answer says this out loud and shows where the line is.
| Trait | What breaks in the naive version | Chapter that fixes it |
|---|---|---|
| Two patterns in one | A div list with click handlers has no role. Screen readers say "group" or nothing. | Which widget to build; ARIA roles and focus |
| Big keyboard model | Only mouse works. Tab jumps past the list. Escape does nothing. | The keyboard model and typeahead |
| Floating popup | Clipped by a card, or opens below the fold on a short laptop screen. | Positioning the popup |
| Long and async lists | 5,000 nodes freeze the phone for 300 ms. Slow responses overwrite fast ones. | Long lists and async options |
| The native rival | Phone users get a tiny custom list instead of the OS picker. | Forms and phones |
Think first
If the native select is so good, why don't design systems just style it and stop there?
Until recently you could style only the closed button. The open list is drawn by the browser or the operating system, and its look, width, and fonts were off limits. Options could hold only plain text: no icons, avatars, two-line descriptions, or colored status dots.
That's changing. Chromium browsers (since version 135) and Safari 27 support a customizable select (appearance: base-select) where the list is real, stylable HTML. Once all target browsers support it, the honest answer becomes "style the native one". Until then, most systems ship a custom select for rich cases and keep the native one where plain text is enough, especially on phones.
Which widget: native, customizable, custom, or menu
Before any API or ARIA detail, decide what you are building. There are four real candidates. They are not competitors so much as tools for different jobs, and a good design system ships more than one of them behind a single, consistent API.
Terms first. A listbox is a list of options where one or more can be selected (role="listbox" with role="option" children). A combobox is an input or button that controls a popup, usually a listbox (role="combobox").
When the combobox can't be typed into, the APG calls it a select-only combobox: that is the ARIA name for a custom select. A menu button is a button that opens a role="menu" with role="menuitem" children.
Decision What should the design system's select be built on?
What we pick: Ship one Select API with two engines. On touch devices and for plain-text lists, render the native select (customizable where supported, so it still matches the design). For rich options, async lists, long virtualized lists, or multi-select on desktop, render the custom select-only combobox. Ship Menu as a separate component on the same internal popup and list engine, because its role, focus, and Enter behavior differ. Teams pick by intent ("value" or "action"), not by look.
Value or action: the one-question test
Ask: after the user picks, does the control still show the choice? "Sort by: Newest" does, so it's a select. "Delete" doesn't (the row is gone), so it's a menu.
A few cases are mixed. A toolbar "Text size" control runs a command but also shows the current size: treat it as a select. A "View" menu with checkable items ("Show sidebar") is a menu with menuitemcheckbox items: the state belongs to the app, not to a form field.
Navigation is the classic trap. A header "Products" dropdown full of links is neither: it's a disclosure, a button with aria-expanded that shows and hides a normal list of links. The menu role promises desktop-app keyboard behavior (arrows, typeahead, focus inside) that users of a website's header don't expect, and it hides the fact that each item is a link.
The customizable select, in a few lines
<select name="status">
<button><selectedcontent></selectedcontent></button>
<option value="open"><span class="dot open"></span> Open</option>
<option value="review"><span class="dot review"></span> In review</option>
<option value="done"><span class="dot done"></span> Done</option>
</select>
<style>
select, ::picker(select) { appearance: base-select; }
::picker(select) { border-radius: 8px; padding: 4px; }
option:checked { font-weight: 600; }
</style>
This is the future of most simple selects: the browser keeps doing the hard parts and you only write CSS. Treat it as progressive enhancement. Ship it, and accept that some users see the classic look until their browser catches up.
Two limits remain. The opt-in also replaces the operating system's picker on phones with the in-page one, so wrap it in @media (pointer: fine) if phones should keep the platform picker. And it doesn't remove the need for the custom engine, which still covers async, virtualized, and multi-select lists.
Check your understanding · Exercise 2
Which of these should use the menu button pattern (role="menu")? Select all that apply.
The component API: what product teams write
The API is the part 40 teams will touch every day, and the part that's hardest to change later. This chapter applies ideas from the component API design lesson; read it for the general rules. Here we only make the select-specific calls.
The naive API is options: string[] plus onChange(label). It breaks in the first week: two products named "Pro" can't be told apart, translated labels change the stored value, and a team that needs an icon per option has nowhere to put it.
Decision How do teams describe the options?
What we pick: Use compound components as the main API, because they read like HTML and handle groups and rich content naturally, and accept an items prop as a shortcut for long or async lists, where the component needs the full data up front to virtualize. Build both on an internal headless hook, which we export later for the rare team that needs it.
// Short lists: reads like HTML
<Select name="sort" label="Sort by" defaultValue="new">
<Select.Option value="new">Newest</Select.Option>
<Select.Option value="old">Oldest</Select.Option>
<Select.Option value="top" disabled>Most liked (Pro)</Select.Option>
</Select>
// Long or async lists: data in, render function out
<Select
name="country"
label="Country"
value={country}
onValueChange={setCountry}
items={countries} // 250 items
getKey={(c) => c.code}
renderItem={(c) => <><Flag code={c.code} /> {c.name}</>}
getTextValue={(c) => c.name} // for typeahead and the trigger
/>
Controlled and uncontrolled
Support both, like a native input. Uncontrolled: the team passes defaultValue and the component keeps the value itself; good for simple forms. Controlled: the team passes value and onValueChange, and the component never changes its own value; good when the value drives other UI, like a filter that refetches a list. Open state gets the same pair: open, defaultOpen, onOpenChange, so a team can open the select from a keyboard shortcut.
Two rules keep this sane. First, the value is a stable key (a string, or an id you map back), never the label, so translations and renames don't change stored data. Second, switching between controlled and uncontrolled during the component's life is a bug: warn in development, as React does for inputs.
Groups, disabled options, multi-select, and async
| Need | API | Behavior that comes with it |
|---|---|---|
| Groups | Select.Group with a label | Rendered as role="group" labelled by its heading. Headings are skipped by arrow keys and typeahead. |
| Disabled option | disabled on an option | Shown dimmed with aria-disabled="true". Skipped by arrows and typeahead in the select. Never picked by Enter. |
| Whole control disabled | disabled on Select | The trigger can't open. Its value is not submitted, matching native fields. |
| Multi-select | multiple, with value as an array | Listbox gets aria-multiselectable="true". Enter or Space toggles and the popup stays open. The trigger shows "Open, In review" or "3 selected". |
| Async options | loadItems(signal) returning a promise of items | Called on first open. Shows loading, empty, and error with retry inside the popup. The AbortSignal cancels it when the popup closes. |
| Placeholder | placeholder="Choose a country" | Shown when the value is empty. It is not an option and can't be picked. |
| Label | label, or aria-labelledby for an outside label | Required. Without a name the control is announced only as "combo box". |
Multi-select deserves a warning in the docs. With more than about 5 values chosen, a select stops being a good fit: users can't see what they've picked without opening it. Point teams to a checkbox list or a tag input there. For a menu, the API is smaller: Menu, Menu.Trigger, Menu.Item with onSelect, Menu.Separator, and optional Menu.CheckboxItem for toggles.
Check your understanding · Exercise 3
A team reports bugs with their select. Which API choices cause them? Select all that apply.
ARIA roles and where focus lives
The ARIA patterns lesson covers roles, states, and properties in general; here we apply them. The rule that matters most: ARIA only changes what assistive tech announces. It adds no behavior. Writing role="listbox" doesn't make arrow keys work; we still write every key handler.
The select's markup
<span id="country-label">Country</span>
<div role="combobox" tabindex="0"
aria-labelledby="country-label"
aria-haspopup="listbox"
aria-expanded="true"
aria-controls="country-list"
aria-activedescendant="opt-jp">Canada</div>
<ul role="listbox" id="country-list" aria-labelledby="country-label">
<li role="group" aria-labelledby="g-asia">
<span id="g-asia" role="presentation">Asia</span>
<ul role="presentation">
<li role="option" id="opt-in" aria-selected="false">India</li>
<li role="option" id="opt-jp" aria-selected="false">Japan</li>
</ul>
</li>
<li role="option" id="opt-ca" aria-selected="true">Canada</li>
</ul>
| Attribute | On | What it tells assistive tech |
|---|---|---|
role="combobox" | Trigger | This controls a popup of choices. Announced as "combo box" or "pop-up button", depending on the screen reader. |
aria-expanded | Trigger | Whether the popup is open. Must flip to false on every close path, including a click outside. |
aria-controls | Trigger | The id of the popup. It must point at a real element; many teams render the listbox only while open and set it only then. |
aria-activedescendant | Trigger | Which option is highlighted while DOM focus stays on the trigger. Remove it when closed. |
aria-selected | Option | Which option is the value. true on the chosen one. In multi-select, every option carries true or false. |
aria-disabled="true" | Option | Can't be chosen. Kept in the tree so users know it exists. |
aria-haspopup="menu" | Menu trigger | A real button that opens a menu. Screen readers say "menu button". |
The trigger is a div with role="combobox", not a button. That's on purpose and matches the APG select-only example: an element with no built-in role takes the combobox role cleanly, with no native button behavior to override. The cost is that we must add tabindex="0" and handle Enter and Space ourselves. The menu trigger is the reverse: a real button, because focus leaves it when the menu opens.
Two ways to move focus through a list
Decision When the user presses Down, what actually gets focus?
What we pick: Use aria-activedescendant for the select, which is what the APG select-only combobox does and what the autocomplete guide uses for the same reason: the trigger stays in charge. Use roving focus for the menu, which matches the APG menu button pattern, where focus moves onto the first item and returns to the button on close.
aria-activedescendant tells assistive tech which option is active. Right: focus really moves, so closing must hand it back to the button.Both choices are honest with screen readers if done fully. The bugs come from half doing one: setting aria-activedescendant to an id that isn't rendered yet, forgetting to clear it on close, or using roving focus and leaving focus on a removed item after the popup unmounts (focus falls to body, and a screen reader user is lost at the top of the page).
What the user hears
- Tab onto the closed select: "Country, combo box, Canada, collapsed" (wording varies by screen reader).
- Press Down to open: "expanded", then "Canada, selected, 3 of 250". The position comes from the DOM, or from
aria-setsizeandaria-posinsetwhen virtualized. - Arrow to Japan: "Japan, 4 of 250". Press Enter: the popup closes and the trigger reads "Japan".
- On a menu button: "Actions, menu button, collapsed". Enter opens and focus lands on "Rename, menu item, 1 of 3".
Check your understanding · Exercise 4
Which of these are real accessibility bugs? Select all that apply.
The keyboard model and typeahead
The keyboard navigation lesson explains focus order and composite widgets in general. A select is a composite: Tab moves onto it once, and arrow keys move inside it. Users bring years of habit from the native select, so we copy its behavior closely, following the APG select-only combobox. Arrow keys don't wrap from last to first in that pattern; menus usually do.
| Key | Closed (focus on trigger) | Open (select) | Open (menu) |
|---|---|---|---|
| Down / Up | Open. Down keeps the current option highlighted; Up moves to the first. | Move the highlight one option, skipping disabled ones. Stop at the ends. | Move focus one item, wrapping at the ends. |
| Home / End | Open and highlight the first or last option. | Highlight the first or last enabled option. | Focus the first or last item. |
| Page Up / Down | Nothing. | Jump about 10 options. | Usually nothing. |
| Enter | Open. | Pick the highlighted option, close, keep focus on the trigger. | Run the item's action, close, return focus to the button. |
| Space | Open. | Pick and close (in multi-select: toggle and stay open). If a typeahead search is in progress, Space is part of the search. | Run the item, like Enter. |
| Escape | Nothing. | Close without changing the value. | Close, return focus to the button. |
| Tab | Move on as usual. | Pick the highlighted option, close, and move to the next field. | Close and move on. |
| Letters | Open and jump to the first match. | Typeahead: jump to the best match. | Typeahead: focus the next match. |
Typeahead with a timeout buffer
Typeahead means jumping to an option by typing its first letters. In a list of 250 countries, it's the fastest way to reach "Switzerland": type "swi" and you're there. The trick is a small buffer of recent keys that clears after a short pause: 500 ms in the APG example, 1 second in React Aria.
- On each printable key, add it to the buffer and restart the clear timer.
- If the buffer is one letter repeated ("sss"), cycle: move to the next option starting with that letter after the current one. This is how users step through "Spain, Sri Lanka, Sudan".
- Otherwise, search from the current option for the first option whose text starts with the whole buffer, case-insensitive and ignoring accents, wrapping to the top.
- If nothing matches, keep the highlight where it is. Don't jump to a random place.
- After the pause, clear the buffer so the next key starts a new search.
const norm = (s: string) =>
s.normalize("NFD").replace(/\p{M}/gu, "").toLocaleLowerCase();
function findMatch(labels: string[], disabled: boolean[], start: number, buffer: string) {
const q = norm(buffer);
const sameLetter = [...q].every((c) => c === q[0]);
const n = labels.length;
// One letter, or one letter repeated: cycle from the next option.
// A longer search includes the current option, so it stays if it still matches.
const offset = sameLetter ? 1 : 0;
const needle = sameLetter ? q[0] : q;
for (let i = 0; i < n; i++) {
const idx = (start + offset + i) % n;
if (!disabled[idx] && norm(labels[idx]).startsWith(needle)) return idx;
}
return -1; // no match: keep the current highlight
}
norm lowercases and strips accents so "o" finds "Österreich".Why does a single letter search after the current option, while a longer buffer includes it? Typing "s" while on "Spain" should move on to "Sri Lanka": the user wants the next S. But once "sw" has landed on "Sweden", typing "e" (buffer "swe") must stay on "Sweden", because it still matches. Starting after it would skip to the wrong country; try both cases in the lab below.
The highlight is on Spain. Press letter keys quickly, or press Pause to let the buffer clear.
Edge cases that bite
- Escape inside a dialog: the select's Escape must close only the select. Call
event.preventDefault()andevent.stopPropagation()after handling it, or the dialog also closes and the user loses their form (the closing-rules chapter explains why both). - Input method editors: users typing Japanese or Chinese compose characters with several keys. Skip typeahead while
event.isComposingis true. - Modifier keys: ignore keys pressed with Ctrl, Cmd, or Alt (except Alt plus arrows), so browser shortcuts like Cmd+L still work.
- Right-to-left: Up and Down don't change in a vertical list, but sub-menus open with Left arrow instead of Right.
- Holding a key down: auto-repeat fires many keydowns. Moving one option per event is fine; re-rendering 250 options per event is not (see the performance chapter).
Check your understanding · Exercise 5
The list is: Spain, Sri Lanka, Sudan, Sweden, Switzerland. The highlight is on Spain. Which of these are correct? Select all that apply.
Positioning the popup near edges and inside scrolling boxes
The naive popup is position: absolute; top: 100% inside a wrapper with position: relative. It works in a demo. In the product it fails three ways, and each has a concrete cause.
- Clipped: the select sits in a card with
overflow: hidden(for rounded corners) or in a scrolling side panel. The popup is a child of that box, so the part outside the box is cut off. - Buried: a sibling section later in the page has
position: relative; z-index: 2. The popup'sz-index: 1000only counts inside its own stacking context (a layer group created by things likez-indexon a positioned element,transform,opacitybelow 1, orisolation). It loses to the whole later section. - Off screen: the select is the last field on a 768 px tall laptop screen. A 320 px popup opens below the fold, and the bottom options can't be reached without scrolling the page, which may close the popup.
overflow: hidden, and its z-index only competes inside its stacking context. The top layer sits outside both.Decision How do we get the popup out of clipping boxes and place it well?
What we pick: Use the Popover API for layering in every browser, since it ends the clipping and z-index fights for good. Use CSS anchor positioning where CSS.supports("anchor-name: --a") is true, and fall back to a small JavaScript placer (flip, shift, max height, auto-update) where it isn't. Keep a portal only as the path for very old browsers. The same layer helper serves the menu, tooltip, and date picker.
Flip, shift, and size: the placement rules
Three rules, applied in order, cover nearly every case. Use a gap of 8 px from the viewport edges and 4 px from the trigger.
- Flip: prefer below. If the space below (
innerHeight - rect.bottom) is less than the popup's height and the space above is larger, open above. - Size: whichever side wins, cap the popup's
max-heightto the space on that side, minus the gap. The list scrolls inside. This is what stops a 600-option list from running off screen. - Shift: if the popup is wider than the trigger and would cross the right edge, slide it left until it fits, but never so far that it no longer touches the trigger's horizontal range.
Worked example: the viewport is 768 px tall, and the trigger runs from 584 px to 620 px (it's 36 px tall). Space below is 768 minus 620, minus the 4 px offset and the 8 px edge gap: 136 px. Space above is 584 minus the same 12 px: 572 px.
The popup wants 320 px. It doesn't fit below, and above has more room, so it flips up. The cap from the space above is 572 px, so the whole 320 px list shows without scrolling. If the trigger's bottom sat at 400 px, below would have 356 px: it fits, so it stays below.
Following the trigger
A position: fixed popup stays put while the page scrolls under it. Either reposition on every scroll event (listen in the capture phase on window, because scroll events from nested containers don't bubble) and on resize, throttled to one update per animation frame; or close the popup when the trigger scrolls out of view. We reposition while the trigger is visible, and close when an IntersectionObserver reports the trigger fully hidden. A ResizeObserver on the trigger and popup handles font loading and zoom.
Check your understanding · Exercise 6
Which statements about positioning are true? Select all that apply.
Long lists and async options
When the list has thousands of options
Measure before optimizing. One option row with an icon and two lines of text is about 6 to 10 DOM nodes. 250 countries is about 2,000 nodes: rendering them on open takes tens of milliseconds on a mid-range phone, which fits the budget. The 5,000-SKU list is about 40,000 nodes, twenty times the work, which can easily take hundreds of milliseconds and blows the 100 ms open budget.
Profile on a real low-end device to confirm. The virtualization lesson covers the technique; here's what changes for a select.
Virtualization renders only the rows in view plus a few extra (the overscan), inside a tall spacer that keeps the scrollbar honest. With 36 px rows and a 320 px popup, that's about 9 visible rows plus 5 overscan on each side: under 20 options in the DOM instead of 5,000.
- The active option must exist:
aria-activedescendantcan only point at a rendered element. When the user presses End, scroll the virtual list to the last index first, render it, then set the reference. Never point at an id that isn't in the DOM. - Position must be stated: a screen reader counts options in the DOM and would say "3 of 19". Put
aria-setsize="5000"andaria-posinseton each rendered option so it says "4,812 of 5,000". - Search the data, not the DOM: typeahead, Home, End, and Page Down work on the items array. The DOM only shows the result.
- Fixed row heights keep the math simple: index times 36 px. Variable heights (wrapped two-line labels) need measured heights and a cached offset table.
- Load the virtualizer lazily: only lists above a threshold (we use 300) import it, so the common 5-option select pays nothing.
Options from the server
Some lists live on the server: a team's projects, a store's warehouses. The naive version fetches in useEffect on every open and drops the result into state. Open, close, and reopen quickly on a slow network, and two requests race: the older, slower one can land last and overwrite fresh data. Close before the reply arrives and the component sets state after it unmounts.
Decision When do we load async options?
What we pick: Prefetch on intent (hover or focus), fall back to on-open, and cache the result by key in the app's server-state cache so other selects with the same source share it. Each call gets an AbortSignal; closing the popup or a newer call aborts the old one, so stale replies never land.
let controller: AbortController | null = null;
async function loadOptions(load: (s: AbortSignal) => Promise<Item[]>) {
controller?.abort(); // a newer open wins
const mine = (controller = new AbortController());
setState({ status: "loading" });
try {
const items = await load(mine.signal);
if (mine.signal.aborted) return; // closed meanwhile
setState(items.length ? { status: "ready", items } : { status: "empty" });
} catch (err) {
if (mine.signal.aborted) return;
setState({ status: "error", retry: () => loadOptions(load) });
}
}
const onClose = () => controller?.abort();
Inside the popup, every state needs a screen reader story. The loading row is not an option: use a role="status" region that says "Loading projects", so it's announced without being reachable by arrows.
The empty state says "No projects yet". The error state says what failed and offers a Retry button. Because DOM focus is on the trigger, make Retry reachable: Enter on an empty, failed list runs retry, or move focus to the button and return it after.
One more async trap: the selected value may not be in the loaded list. The saved filter says warehouse: "w-17" but the list hasn't loaded yet, or w-17 was deleted. The trigger needs a label anyway, so the API takes the selected item's label from the saved data (selectedItem), or shows "Unknown warehouse" rather than a raw id.
Check your understanding · Exercise 7
Which are correct for a virtualized, async select? Select all that apply.
Closing rules and returning focus
A popup can close in at least seven ways: pick an option, Escape, Tab, a click outside, a click on the trigger again, the trigger scrolling away, and the component unmounting because the page changed. The bug pattern is always the same: one path forgets something. It leaves aria-expanded wrong, leaves focus on a removed node, or fires onOpenChange twice. The fix is structural: every path calls one close(reason) function, and that function does all the cleanup in a fixed order.
type CloseReason = "select" | "escape" | "tab" | "outside" | "toggle" | "scroll";
function close(reason: CloseReason) {
if (!state.open) return; // idempotent: safe to call twice
abortLoad(); // cancel pending async options
setState({ open: false, activeIndex: -1 });
trigger.removeAttribute("aria-activedescendant");
trigger.setAttribute("aria-expanded", "false");
// Tab and outside clicks move focus somewhere new on purpose: don't steal it back.
if (reason !== "tab" && reason !== "outside") trigger.focus({ preventScroll: true });
props.onOpenChange?.(false, reason);
}
Where focus goes
The focus management lesson covers the general rule: when something that holds focus disappears, put focus somewhere sensible, usually where it came from. For the select, focus never left the trigger, so most closes need nothing. For the menu, focus moved onto an item, so closing after Enter or Escape must put it back on the menu button. Two exceptions: on Tab, the user is moving on, so let focus go to the next field; on a click outside, the user clicked something else, so let that thing have focus.
A menu item's action can remove the trigger itself. "Delete" on a row deletes the row and its menu button. Returning focus to a detached node drops it on body. The menu calls onSelect after it returns focus, and the row's owner moves focus to a sensible neighbor (the next row, or the list heading) when it removes the row.
Detecting a click outside
Listen for pointerdown on the document in the capture phase while open. If the event target is inside neither the trigger nor the popup, close with reason outside. Use pointerdown, not click: a drag that starts inside and ends outside shouldn't close it, and a press on another control should close this popup before that control acts.
Ignore the trigger itself, or the press closes the popup and the following click reopens it. With popover="auto", the browser does light dismiss for us, and we listen for its toggle event to keep our state in sync.
body.Think first
A select sits inside a modal dialog. The user opens the select and presses Escape. What should close, and what code makes that happen?
Only the select should close. The user is still in the dialog with a half-filled form, and closing the dialog would throw that work away.
The select's keydown handler runs first, because the event starts at the focused trigger inside the dialog. It closes the popup and calls event.preventDefault() and event.stopPropagation(). In current browsers, a canceled Escape keydown doesn't reach the native dialog's close request, but behavior here has changed over time, so cover it with a test in each browser. If the popup is a popover="auto", the browser's own Escape handling closes the topmost popover first, which also gives the right result.
Check your understanding · Exercise 8
Which closing behaviors are right? Select all that apply.
Forms and phones
A select usually lives in a form. The form state lesson covers values, validation, and submission in general. A native field gets four things for free that a div with role="combobox" does not: its value goes into FormData under its name, required blocks submit and shows a message, a form reset restores the default, and the browser's autofill can fill it ("Country" from a saved address). A custom select must earn each one back.
Decision How does the custom select take part in a form?
What we pick: In the React system, render a hidden input for the value, and handle required ourselves: on the form's submit (and in our form library's validation) check the value, show the error under the field, link it with aria-describedby, set aria-invalid="true", and focus the trigger. Where autofill matters (addresses, checkout), use the native engine, which is also what phones get. For the framework-agnostic web component version of the system, use ElementInternals; the web components lesson shows the full pattern.
class CfSelect extends HTMLElement {
static formAssociated = true;
#internals = this.attachInternals();
#value: string | null = null;
set value(v: string | null) {
this.#value = v;
this.#internals.setFormValue(v); // null removes it from FormData
this.#validate();
}
#validate() {
const missing = this.hasAttribute("required") && !this.#value;
const anchor = this.shadowRoot?.querySelector("[role=combobox]") ?? undefined;
this.#internals.setValidity(
missing ? { valueMissing: true } : {},
missing ? "Choose an option" : "",
anchor as HTMLElement | undefined,
);
}
formResetCallback() { this.value = this.getAttribute("value"); }
}
internals makes the element a real form field.Phones: let the platform do it
On a phone, the native select opens the operating system's own picker: a large native list or menu (its exact look depends on the OS and version), with no positioning, keyboard, or zoom problems. A custom popup on a 375 px wide screen must solve all of those again, plus the on-screen keyboard and the visual viewport shrinking under it. For a plain-text list, the native picker is simply the better experience.
- Pick the engine with
matchMedia("(pointer: coarse)"), not by user agent. A touch laptop with a mouse attached reportsfineand gets the custom popup; a phone reportscoarseand gets native. - Decide once, on the client, and don't switch while open. For server rendering, render the native select first: it works before JavaScript loads and the custom engine takes over on desktop after hydration.
- Rich options that the native picker can't show (avatars, descriptions) become a bottom sheet on phones: a full-width panel that slides up from the bottom, behaving like a small modal dialog with focus inside and a clear Done button.
- Touch targets: every option row at least 44 px tall on touch (WCAG 2.2 AA asks for at least 24 by 24 CSS pixels; platform guidelines ask for more).
Check your understanding · Exercise 9
A custom select uses only input type="hidden" with required on it. Which of these happen? Select all that apply.
Architecture, data model, and the state machine
Now put the decisions together. The design has one idea at its center: a small headless engine owns all state and behavior, and thin visual parts render it. The select and the menu are two configurations of the same engine, and the native select is a separate, much simpler path chosen up front.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
Select / Menu root | Reads props, picks the engine (native or custom), provides context, owns controlled or uncontrolled value and open state. | The only part teams configure. Everything below can change without breaking them. |
| Collection | Turns children or items into one flat, indexed list of items, groups, and separators, with keys, text values, and disabled flags. | Keyboard, typeahead, and the virtualizer all need the same list. Building it once avoids three copies that drift. |
| Interaction engine | A state machine for open and close, the active index, keyboard handling, typeahead buffer, and the single close(reason) path. | Pure logic with no DOM: unit-tested with hundreds of key sequences in milliseconds. |
| Layer and placer | Popover top layer, anchor positioning or the JavaScript fallback, outside-press detection, scroll and resize tracking. | Shared with tooltip, date picker, and command palette. Positioning bugs get fixed once. |
| Listbox / menu view | Renders options, groups, and states (loading, empty, error) with the right roles and ids. | Visual and ARIA, nothing else. Easy to theme. |
| Virtualizer | Renders a window of rows for lists above the threshold. | Loaded lazily, so short lists don't pay for it. |
| Async loader | Calls loadItems(signal), dedupes, caches by key, aborts on close. | Keeps network rules out of the engine. |
| Form bridge | Hidden input(s), required check, reset listener. | Forms differ between React and the web component build; only this part changes. |
The data model
Everything here is component state. Nothing goes into a global store: two selects on one page share no state, except async option lists, which live in the app's server-state cache keyed by source. The value can also live in the URL when it's a filter (?sort=new), and then the select is simply controlled by the router.
type Key = string;
type CollectionItem =
| { kind: "option"; key: Key; textValue: string; disabled: boolean; index: number }
| { kind: "group"; key: Key; label: string; childKeys: Key[] }
| { kind: "separator"; key: Key };
type LoadState =
| { status: "idle" }
| { status: "loading" }
| { status: "ready" }
| { status: "empty" }
| { status: "error"; message: string };
type SelectState = {
open: boolean;
selected: ReadonlySet<Key>; // one key in single mode
activeKey: Key | null; // highlighted option while open
typeahead: { buffer: string; clearAt: number };
placement: "bottom" | "top"; // after flip, for styling the arrow and animation
load: LoadState;
};
Why keys and a Set instead of option objects? Objects from an async source get replaced on every refetch, so identity comparison breaks. Keys survive refetches, sorting, and virtualization, and a Set makes multi-select toggles and aria-selected checks constant time.
Enter while loading does nothing, and Escape while closed is left alone so a parent dialog can use it.A state machine lists the states a thing can be in and which events move it between them. It keeps the rules honest, because some events are only legal in some states.
select is ignored while loading. Escape while closed does nothing, so it bubbles up to a parent dialog. Tab while open both picks and closes. Writing it as a machine turns "what if the user presses Enter while it's still loading?" from a bug report into a row in a table.
onValueChange and waits for the new value.aria-activedescendant reference.Check your understanding · Exercise 10
Which design choices here make the component easier to test and change? Select all that apply.
Performance, motion, languages, and testing
Performance: open fast, move faster
Two moments matter. Open: from click to a painted list, under 100 ms. Move: each arrow press repaints within one frame. Both feed Interaction to Next Paint (INP), the Core Web Vital that measures how long the page takes to visibly respond to a tap, click, or key press. A good INP is 200 ms or less at the 75th percentile. A select that freezes for 300 ms on open drags the whole page's INP into the "needs improvement" band, because it's one of the most-clicked controls.
- Don't re-render every option on each arrow press. If
activeKeylives in the root and every option reads it from context, a key press re-renders all 250 options. Instead, set adata-activeattribute on the old and new option directly, or subscribe each option to "am I active?" with a selector so only two options update. - Style the active state with CSS: one
[data-active]rule in the stylesheet, not a per-option prop that forces a render. - Mount the popup lazily: render the listbox only on first open, then keep it mounted but hidden if the list is short. Reopening is then instant.
- Keep the core small: the engine, layer, and views fit in about 6 KB compressed. The virtualizer and the JavaScript placer are separate chunks loaded only when needed.
- Measure once per frame: positioning reads layout (
getBoundingClientRect) and then writes styles. Batch reads before writes inside onerequestAnimationFrame, so scrolling doesn't cause repeated forced layouts.
Motion
A short fade and 4 px slide from the trigger's side (down when below, up when flipped) helps the eye connect the popup to its trigger. Keep it under about 150 ms, never delay keyboard input until it ends, and turn it off under prefers-reduced-motion: reduce. With the Popover API, CSS can animate both opening and closing using @starting-style and transition-behavior: allow-discrete on display and overlay.
Languages and right-to-left
- Sort with the locale:
Intl.Collator(locale).compareputs "Österreich" where German readers expect it. A plainsort()sorts by code units, which puts accented letters after Z. - Typeahead per locale: normalize accents for matching, and skip typeahead while an input method is composing.
- Right-to-left: use logical CSS (
inset-inline-start,padding-inline), and the popup aligns to the trigger's inline start. With anchor positioning,span-inline-endalready follows the writing direction. - Long labels: German labels run about 30% longer than English. Let the popup grow wider than the trigger up to a max, and wrap long option text instead of truncating, since truncated options are hard to tell apart.
High contrast and zoom
In Windows high contrast mode (forced-colors: active), background colors vanish, so an active option shown only by a light background disappears. Add an outline or use system colors like Highlight for the active and selected states. At 200% zoom on a 1,280 px laptop, the viewport is effectively 640 px wide: the size and shift rules from the positioning chapter keep the popup on screen.
Testing
| Layer | What it catches | Tool |
|---|---|---|
| Engine unit tests | Every key in every state, typeahead timing (with fake timers), disabled skipping, the close path. | Vitest or Jest, no DOM. |
| Component tests | Roles, names, aria-expanded and aria-activedescendant flips, form submission, reset. | Testing Library queries by role, plus axe for automatic rule checks. |
| Browser tests | Flip near edges, clipping inside scroll boxes and dialogs, focus return, mobile engine choice. | Playwright in Chromium, Firefox, and WebKit, at several viewport sizes. |
| Manual screen reader passes | What users really hear. Automated tools can't judge this. | VoiceOver with Safari, NVDA and JAWS with Chrome or Firefox, TalkBack on Android, before each major release. |
Check your understanding · Exercise 11
Holding the Down key in a 250-option select makes the page stutter. Which fixes help? Select all that apply.
Running it in the interview
A component question rewards depth over breadth. You won't draw servers. You will be judged on whether you know what the platform gives you, what ARIA means, and what breaks at the edges. Here's a plan for 45 minutes.
| Minutes | What you do | What to say out loud |
|---|---|---|
| 0 to 5 | Ask the scope questions: value or action, list size, async, multi, forms, phones. | "Is this for picking a value, or running actions? They're different ARIA patterns, so I want to be sure." |
| 5 to 10 | Native versus custom. Name the four options and pick per case. | "On touch and for plain text I'd ship the native select. Custom is for rich, async, long, or multi-select on desktop." |
| 10 to 18 | The API: compound components, controlled and uncontrolled, groups, disabled, multi, async. | "Values are stable keys, never labels." |
| 18 to 28 | Draw the wireframe and the markup. Roles, aria-expanded, aria-controls, aria-activedescendant, the key table, typeahead. | "Focus stays on the trigger for the select; it moves into the list for a menu." |
| 28 to 36 | Positioning: clipping, stacking, flip and shift, top layer, anchor positioning with a fallback. | "z-index can't escape a stacking context; the top layer can." |
| 36 to 42 | Long lists, async races, forms, phones. | "With virtualization, the active option must be rendered before I point at it." |
| 42 to 45 | Architecture recap and testing. | "A headless engine, shared by select and menu, tested without a DOM." |
What to draw first
Draw the wireframe of the open select with its parts labelled (label, trigger, popup, group heading, options, active and selected states), then write the ARIA attributes right on it. It anchors every later question to a picture. Draw the architecture boxes only after the behavior is clear; for a component question the interviewer cares more about the behavior than the boxes.
Follow-up questions to expect
Think first
"Now make it searchable." What changes?
It becomes an editable combobox: a text input with role="combobox" and aria-autocomplete="list" controls the listbox. Focus stays in the input, so aria-activedescendant keeps working. Typeahead is replaced by filtering, and Home and End go back to moving the text cursor.
The popup, placer, collection, virtualizer, and async loader all stay. That's the payoff of the headless split. The autocomplete guide covers the filtering, debouncing, and request races in depth.
Think first
"Add sub-menus to the actions menu." What do you need to handle?
Items with sub-menus get aria-haspopup="menu" and aria-expanded. Right arrow (Left in right-to-left) opens the sub-menu and focuses its first item; Left arrow or Escape closes only the sub-menu and returns focus to its parent item.
Positioning flips sideways: prefer the inline end, flip to the start near the edge. Pointer users need a short open delay and a forgiving path toward the sub-menu, so moving diagonally across a neighbor item doesn't close it. Each level is its own popover in the top layer.
Think first
"Why not just use the native select everywhere?"
For many cases I would: it's accessible, fast, works in forms, and gives phones the platform picker. With appearance: base-select, Chromium and Safari 27 users already get full styling, and Firefox is expected to follow.
I'd still need the custom engine for options with rich content in browsers without the customizable select, for async and very long lists, and for a usable multi-select. So the system ships both behind one API and picks per case.
Think first
"How do you know it's accessible?"
Three layers. Automated checks (axe in component tests) catch missing names and invalid roles, but only a part of real issues. Keyboard tests in Playwright cover every key path and focus return. Then manual passes with VoiceOver, NVDA, JAWS, and TalkBack, because only a person can judge whether the announcements make sense.
Ideally, people who use assistive tech every day try it before release, since their habits differ from a tester's.
Check your understanding · Exercise 12
Which statements would raise your score in this interview? Select all that apply.
Summary
- Ask first: value or action. A select (listbox, select-only combobox) stores a value; a menu button runs a command. Navigation links are a disclosure, not a menu.
- Use the native select on touch devices and for plain-text lists, and the customizable select where supported. Build the custom engine for rich, async, long, or multi-select lists.
- Values are stable keys, never labels. Support controlled and uncontrolled value and open state, and don't switch between them.
- For the select, DOM focus stays on the trigger and
aria-activedescendantpoints at the highlighted option. For the menu, focus moves onto items and returns to the button on close. - Copy the native keyboard model, including typeahead with a short-lived buffer that cycles on a repeated letter and keeps the current option when a longer search still matches.
- Clipping and
z-indexfights end in the top layer (Popover API). Place with CSS anchor positioning and a JavaScript fallback, using flip, size, and shift. - Route every close path through one function that updates
aria-expanded, clears the active option, aborts loads, and returns focus when right. - With virtualization, render the active option before referencing it and state
aria-setsizeandaria-posinset. Abort stale async loads. - In forms, bridge the value with a hidden input or
ElementInternals, and dorequiredand reset yourself if the field isn't native.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- WAI-ARIA APG: Select-only combobox example The keyboard table and markup this guide follows for the select.
- WAI-ARIA APG: Menu button pattern Roles, focus, and keys for the actions menu.
- WAI-ARIA APG: Listbox pattern Selection, multi-select, and typeahead in listboxes.
- MDN: Customizable select elements How
appearance: base-selectworks, with support notes. - Chrome for Developers: The select element can now be customized with CSS The announcement and design of the customizable select.
- MDN: Popover API Top layer, light dismiss, and the toggle event.
- MDN: CSS anchor positioning
anchor-name,position-area, and fallbacks. - MDN: ElementInternals Form-associated custom elements: value, validity, and reset.
- Floating UI documentation A JavaScript placer with flip, shift, and size middleware, for the fallback path.
Concepts behind it
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Semantic HTML firstUsing the HTML element that already means what you need, like
button,a,nav, andh2, so keyboards, screen readers, and voice control understand the page with no extra code. - Web components and shadow DOMThe browser's own component model: custom HTML tags with their own lifecycle, a shadow tree that keeps their markup and styles private, and slots for the page's content, so one component works in React, Vue, Angular, or plain HTML.