cf.completefrontend
My progress
SYSTEM DESIGN · #11

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.

QuestionAnswer we assumeWhat 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.
Anatomy of an open custom select. A settings page shows the label Country above a trigger showing Canada, which has focus. Below it floats the popup listbox. It has a group heading Asia with the options India and Japan, where Japan is highlighted as the active option, then a group heading Americas with Canada, the selected option, and Mexico, which is disabled. Notes on the right name each part: the trigger shows the value and keeps focus, a group heading is not an option, Japan is active, Canada is selected and Mexico is disabled.settings.example.comCountryCanada vAsiaIndiaJapanAmericasCanadaMexicotrigger: showsvalue, has focusgroup heading:not an optionJapan: active(highlighted)Canada: selectedMexico: disabled
Two different states live in the list: the active option (where the keyboard is) and the selected option (the value). Keeping them apart is the root of most ARIA and keyboard decisions in this guide.

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.

TraitWhat breaks in the naive versionChapter that fixes it
Two patterns in oneA div list with click handlers has no role. Screen readers say "group" or nothing.Which widget to build; ARIA roles and focus
Big keyboard modelOnly mouse works. Tab jumps past the list. Escape does nothing.The keyboard model and typeahead
Floating popupClipped by a card, or opens below the fold on a short laptop screen.Positioning the popup
Long and async lists5,000 nodes freeze the phone for 300 ms. Slow responses overwrite fast ones.Long lists and async options
The native rivalPhone 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?

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?

Native select element

Render a real select with option and optgroup children. Style the closed box with CSS; the open list is drawn by the browser or the operating system.

Pros

  • Free accessibility, keyboard model, typeahead, and form support, tested for decades.
  • On phones it opens the platform picker: big touch targets, no positioning bugs.
  • Zero JavaScript, and it can't be clipped by a parent, since the list isn't part of the page layout.

Cons

  • Options are plain text only: no icons, avatars, or second line.
  • The open list's look varies by OS and can't match the design system on most browsers.
  • multiple renders an always-open box that many users don't know how to use (Ctrl-click to add).

Customizable native select

Opt in with appearance: base-select on the select and on its ::picker(select) part. The list becomes real HTML in the top layer, so options can hold icons and rich markup, and a selectedcontent element mirrors the chosen option into the button.

Pros

  • Keeps the native keyboard model, accessibility, and form behavior, while allowing full styling.
  • The popup is anchored and flips with CSS. No JavaScript positioning.
  • Browsers without support fall back to a normal select, which still works.

Cons

  • Shipped in Chromium browsers from version 135 (2025) and in Safari 27 (September 2026). Firefox had not shipped it at the time of writing, so the styled version won't reach every user yet.
  • No async loading, virtualization, or multi-select popup in the new mode.
  • Not a menu: it can only pick values.

Custom select-only combobox

A focusable trigger with role="combobox" controls a popup role="listbox". DOM focus stays on the trigger; aria-activedescendant points at the highlighted option. All keys, positioning, and form wiring are ours.

Pros

  • Any option content, any styling, works the same in every browser.
  • Supports what native can't: async options, virtualization, a usable multi-select, custom empty and error states.

Cons

  • Everything native gave us for free is now our code and our bugs: easily 600 to 1,000 lines with tests.
  • Needs testing with VoiceOver, NVDA, JAWS, and TalkBack, which announce aria-activedescendant with differences.

Menu button

A real button with aria-haspopup="menu" and aria-expanded opens a role="menu". Focus moves into the menu onto the first item. Enter runs the item's action and closes.

Pros

  • Tells assistive tech "these are commands", so users expect an action, not a stored value.
  • Shares the popup, positioning, and list keyboard code with the select.

Cons

  • Wrong for picking values (no selected state is announced) and wrong for site navigation links, where a plain disclosure list of links is simpler and better.

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.

Choosing the pattern. The first question is what happens after picking: does the choice stay? If a value stays, use a select: the native select for touch devices and plain text, or the custom select-only combobox for rich, async, long, or multi-select lists. If an action runs, use a menu button for commands, or a disclosure for navigation links. A note at the bottom says the customizable select styles the native one where supported.after pickingdoes the choice stay?a value staysaction runsnative selecttouch, plain textcustom selectrich, async, longmenu buttoncommandsdisclosurenav linksbase-select styles the native one where supported
Teams choose by intent first and by look last. The native path is the default; the custom engine is earned by a real need the native one can't meet.

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

html
<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>
Opting in to the customizable select. Browsers without support ignore the CSS and show a classic select.

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?

Data props

Pass options as an array of objects (value, label, disabled, group), plus renderOption for custom content.

Pros

  • Short to write. Easy to feed from an API response.
  • The component knows the whole list up front, so typeahead, virtualization, and counts are easy.

Cons

  • Custom layouts squeeze through render callbacks.
  • Groups and separators need extra fields or a nested shape.

Compound components

Teams write JSX: Select wrapping Select.Trigger, Select.Content, Select.Group, Select.Option. Children register themselves through context.

Pros

  • Reads like HTML. Any markup inside an option. Groups and separators are just elements.

Cons

  • The component only learns the list after children render, which complicates typeahead and virtualization of 5,000 items.
  • More pieces to document.

Headless hook

Export useSelect() returning state and prop getters (getTriggerProps, getOptionProps). Teams render everything.

Pros

  • Total freedom. The logic is tested once and reused.

Cons

  • Every team re-creates the visuals and can break ARIA by forgetting a prop getter.
  • Too low level as the main API for a design system.

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.

tsx
// 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
/>
Two ways to write the same select. Both end up in the same internal state.

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

NeedAPIBehavior that comes with it
GroupsSelect.Group with a labelRendered as role="group" labelled by its heading. Headings are skipped by arrow keys and typeahead.
Disabled optiondisabled on an optionShown dimmed with aria-disabled="true". Skipped by arrows and typeahead in the select. Never picked by Enter.
Whole control disableddisabled on SelectThe trigger can't open. Its value is not submitted, matching native fields.
Multi-selectmultiple, with value as an arrayListbox gets aria-multiselectable="true". Enter or Space toggles and the popup stays open. The trigger shows "Open, In review" or "3 selected".
Async optionsloadItems(signal) returning a promise of itemsCalled on first open. Shows loading, empty, and error with retry inside the popup. The AbortSignal cancels it when the popup closes.
Placeholderplaceholder="Choose a country"Shown when the value is empty. It is not an option and can't be picked.
Labellabel, or aria-labelledby for an outside labelRequired. 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

html
<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>
A select-only combobox while open, with "Japan" highlighted and "Canada" selected.
AttributeOnWhat it tells assistive tech
role="combobox"TriggerThis controls a popup of choices. Announced as "combo box" or "pop-up button", depending on the screen reader.
aria-expandedTriggerWhether the popup is open. Must flip to false on every close path, including a click outside.
aria-controlsTriggerThe 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-activedescendantTriggerWhich option is highlighted while DOM focus stays on the trigger. Remove it when closed.
aria-selectedOptionWhich option is the value. true on the chosen one. In multi-select, every option carries true or false.
aria-disabled="true"OptionCan't be chosen. Kept in the tree so users know it exists.
aria-haspopup="menu"Menu triggerA 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?

aria-activedescendant

DOM focus never leaves the trigger. Keys update the trigger's aria-activedescendant to the id of the highlighted option, and we style that option as active.

Pros

  • The trigger keeps focus, so its key handler sees every key, and closing needs no focus return.
  • Options don't need to be focusable; there's no focus fight with the browser's scrolling.

Cons

  • We must scroll the active option into view ourselves (scrollIntoView({ block: "nearest" })).
  • The referenced option must exist in the DOM, which matters for virtualized lists.
  • Support has been weaker in some screen reader and browser pairs, notably older Safari with VoiceOver, so it needs testing.

Roving tabindex

Each item has tabindex="-1" except the current one. Keys call .focus() on the next item, so real DOM focus moves into the list.

Pros

  • Real focus is the most widely supported thing a screen reader can follow.
  • The browser scrolls the focused item into view.

Cons

  • Focus left the trigger, so it must be returned on close.
  • Every item must exist in the DOM to take 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.

Two focus models compared. On the left, the select uses aria-activedescendant: DOM focus stays on the trigger Country: Canada, and an arrow from the trigger points at the highlighted option Japan, while Canada is the selected option. The trigger's attribute reads activedescendant equals opt-jp. On the right, the menu uses roving tabindex: the Actions button no longer has focus, and real focus sits on the Rename item, which has tabindex 0 while the others have tabindex minus 1. Focus returns to the button on close.select: activedescendantCountry: CanadaDOM focus stays hereIndiaJapanCanadaactivedescendant=opt-jpmenu: roving tabindexActionsfocus moved into the menuRename (0)Duplicate (-1)Delete (-1)tabindex 0 / -1focus returns on close
Left: one element holds focus and 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-setsize and aria-posinset when 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.

KeyClosed (focus on trigger)Open (select)Open (menu)
Down / UpOpen. 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 / EndOpen and highlight the first or last option.Highlight the first or last enabled option.Focus the first or last item.
Page Up / DownNothing.Jump about 10 options.Usually nothing.
EnterOpen.Pick the highlighted option, close, keep focus on the trigger.Run the item's action, close, return focus to the button.
SpaceOpen.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.
EscapeNothing.Close without changing the value.Close, return focus to the button.
TabMove on as usual.Pick the highlighted option, close, and move to the next field.Close and move on.
LettersOpen 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.

  1. On each printable key, add it to the buffer and restart the clear timer.
  2. 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".
  3. 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.
  4. If nothing matches, keep the highlight where it is. Don't jump to a random place.
  5. After the pause, clear the buffer so the next key starts a new search.
ts
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
}
The matching part of typeahead. 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.

Rule

The highlight is on Spain. Press letter keys quickly, or press Pause to let the buffer clear.

Typeahead lab with the buffer with timeout rule. The buffer is empty. The highlighted option is Spain. The list holds Peru, Poland, Portugal, Qatar, Spain, Sri Lanka, Sudan, Sweden, Switzerland, Syria, Togo, Tonga.PeruPolandPortugalQatarSpainSri LankaSudanSwedenSwitzerlandSyriaTogoTongabuffer(empty)highlightedSpainrule: buffer, clears on pause
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() and event.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.isComposing is 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's z-index: 1000 only counts inside its own stacking context (a layer group created by things like z-index on a positioned element, transform, opacity below 1, or isolation). 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.
Why popups get clipped. On the left, the popup is a child of a card with overflow hidden. Only its first option, India, shows inside the card; the rest of the list below the card's edge is cut off. On the right, the same popup is placed in the top layer. It extends past the card and shows India, Japan, Canada, and Mexico in full.popup inside the cardcard: overflow hiddenCountry vIndiacut offpopup in the top layercard: overflow hiddenCountry vIndiaJapanCanadaMexicorest of the list hiddennothing clipped
A child can never paint outside an ancestor with 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?

Portal plus JavaScript positioning

Render the popup into a container at the end of body (a React portal). Measure the trigger with getBoundingClientRect(), compute top and left, and set them with position: fixed. A library like Floating UI adds flip, shift, size, and auto-update on scroll and resize.

Pros

  • Works in every browser in use today.
  • Full control over flip and shift rules, and the max height.

Cons

  • About 3 to 10 KB of code and a measurement on every scroll frame.
  • Still fights stacking contexts: a modal dialog in the top layer covers a portal in body.
  • The popup moves far from the trigger in the DOM, so screen reader reading order relies on aria-controls.

Popover API with JavaScript positioning

Put popover on the listbox and call showPopover(). The browser moves it into the top layer: a layer above every stacking context, where overflow and z-index of ancestors don't apply. JavaScript still computes top and left.

Pros

  • Clipping and z-index problems disappear. It even stacks above an open modal dialog, in open order.
  • Works in all major browsers since 2024. The DOM stays next to the trigger.

Cons

  • Still needs measuring code for placement.
  • popover="auto" light dismiss closes it on outside click and Escape; we must sync our state through the toggle event.

Popover API with CSS anchor positioning

Give the trigger anchor-name: --country and the popup position-anchor: --country, position-area: block-end span-inline-end, and position-try-fallbacks: flip-block. The browser places it and flips it, with no script.

Pros

  • No measuring, no scroll listeners, no forced layout from our script: the browser keeps the popup attached as things move.
  • Tiny code.

Cons

  • Newer: Chromium since version 125, Safari since 26, and Firefox since 147 (January 2026). Older browsers still in use need the JavaScript fallback.
  • Less control over custom rules like "shift but keep the arrow pointing at the trigger".

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.

  1. 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.
  2. Size: whichever side wins, cap the popup's max-height to the space on that side, minus the gap. The list scrolls inside. This is what stops a 600-option list from running off screen.
  3. 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.
Rules
Placement lab. A 640 by 768 viewport, drawn at half size. The trigger's top is at 584 px and its left at 440 px. Space below is 136 px and space above is 572 px. The popup wants 320 px and opens above, 320 px tall, at left 392 px. It fits on screen.below: 136 pxabove: 572 pxopens: abovemax-height: 320 pxleft: 392 pxfits on screengreen box: triggerdashed: the viewport
The popup opens above the trigger, 320 px tall, shifted 48 px left to stay on screen.

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-activedescendant can 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" and aria-posinset on 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?

On mount

Fetch as soon as the select renders.

Pros

  • The list is ready when the user opens it.

Cons

  • A settings page with 12 selects fires 12 requests nobody may need.

On open

Fetch when the popup first opens, show a loading row, cache the result for the session.

Pros

  • Only pays for selects people use.

Cons

  • The first open shows a spinner for one round trip (about 100 to 300 ms).

On intent

Start the fetch on pointerenter or focus of the trigger, before the click.

Pros

  • Often hides part of the round trip, since there is usually a short gap between pointing at a button and clicking it.

Cons

  • Some wasted requests from passing pointers; needs dedupe.

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.

Loading options with a failure and a retry. The user hovers over the trigger, so the select asks the loader to prefetch, and the loader sends GET warehouses to the API. The user clicks to open and sees a loading message. The API answers 503. The loader reports status error to the select, which shows an error with a Retry button. The user presses Enter to retry. The select asks the loader to retry, the loader sends the request again with a fresh abort signal, and this time the API returns the items and the list renders.userselectloaderAPIhover triggerprefetchGET /warehousesclick: Loading503error, RetryEnter on Retryretry()GET again200 items, render
The prefetch on hover hides part of the round trip. The failure is shown inside the open popup, with Retry reachable from the keyboard, and the second request carries a fresh abort signal.
ts
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();
Loading options with cancellation. Only the newest request may write state.

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.

ts
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);
}
One close path. The reason decides only whether focus goes back to the trigger.

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.

Deleting a row from its actions menu. The user presses Enter on the menu button. The button opens the menu and focus moves to Rename. The user presses ArrowDown twice and then Enter on Delete. The menu closes and returns focus to the button. The menu calls onSelect delete on the app. The app removes the row and, because the button is gone, moves focus to the next row's button, which the user lands on.usermenu buttonmenuappEnteropen, focus RenameDown, Down, Enterclose, focus backonSelect(delete)button removedfocus next row
Focus goes back to the button first, then the action runs. When the action removes the button itself, the row owner picks the next sensible place, so focus never falls to 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?

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?

Hidden input

Render input type="hidden" with the name and current value. For multi-select, one hidden input per chosen value, all with the same name.

Pros

  • Trivial. The value reaches FormData and plain HTML posts.

Cons

  • Hidden inputs are skipped by constraint validation, so required does nothing.
  • Autofill ignores them, and a form reset doesn't reach our component state.

Visually hidden native select

Render a real select with the same options, hidden with a visually-hidden class and aria-hidden="true" and tabindex="-1", kept in sync with our value. Some popular libraries do this.

Pros

  • Native required validation, reset, and autofill all work, because the browser sees a real field.
  • Listening to its change event picks up autofill values.

Cons

  • Duplicates every option in the DOM, which hurts with async or 5,000 options.
  • The browser's validation bubble points at an invisible element; we show our own error message and focus our trigger in the invalid event.

Form-associated custom element

Build the select as a web component with static formAssociated = true. Call this.attachInternals(), then internals.setFormValue(value) and internals.setValidity({ valueMissing: true }, "Choose a country", trigger).

Pros

  • A real form participant: value, validity, formResetCallback, formDisabledCallback, and the :invalid pseudo-class.
  • No duplicate options.

Cons

  • Only for custom elements, so a React design system has to wrap one.
  • Autofill support for custom elements is limited.

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.

ts
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"); }
}
The web component path. 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 reports fine and gets the custom popup; a phone reports coarse and 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.

Component architecture of the select and menu. Inside the component zone, the Select or Menu root holds the value, open state, and engine choice, and receives props in and sends events out. It feeds three parts: the Collection of items and keys, the interaction engine with keys, typeahead, and close, and the layer and placer using popover and anchor positioning. Below them: the views render the trigger and list with roles and ids, the virtualizer is loaded lazily for lists over 300 items, and the form bridge writes a hidden input. Outside the component: an options cache in the app's server state, which fetches from the options API and feeds the collection, and the HTML form, which receives the name and value.Select / Menu componentSelect rootvalue, open, engineprops in, events outCollectionitems, keysEnginekeys, typeaheadLayer, placerpopover, anchorViewsroles, ids, statesVirtualizerlazy, 300+ itemsForm bridgehidden inputoptions cacheserver stateoptions APIGET, abortableHTML formFormDataname, value
The engine has no DOM and the views have no logic. Shared services (the layer, the cache) live outside the select, so the tooltip, date picker, and menu reuse them.
PartResponsibilityWhy the boundary is here
Select / Menu rootReads 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.
CollectionTurns 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 engineA 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 placerPopover 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 viewRenders options, groups, and states (loading, empty, error) with the right roles and ids.Visual and ARIA, nothing else. Easy to theme.
VirtualizerRenders a window of rows for lists above the threshold.Loaded lazily, so short lists don't pay for it.
Async loaderCalls loadItems(signal), dedupes, caches by key, aborts on close.Keeps network rules out of the engine.
Form bridgeHidden 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.

ts
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;
};
The engine's state. Items are flattened once; the state stores indexes and keys, never DOM nodes.

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.

State machine of the select. From closed, opening with items already known goes straight to open; opening with async items goes to loading. Loading goes to open when items arrive, or to error when the request fails. Retry goes from error back to loading. In open, arrows and typeahead keep it open. Enter, Escape, Tab, or a click outside go from open back to closed. Escape or a click outside also closes from loading or error.closedloadingerroropenopen, asyncopen, syncitems arrivefailsretryEnter, Escape, Tab, outsidearrows, typeaheadstay openEscape or an outside click also closes from loading or error
Events only count in some states: 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.

Data flow for the select. On the left, the options API sends JSON items to the options cache, which passes items to the collection. In the middle, the user's key event reaches the engine, which computes the next state and sends it to the views, which re-render only the changed rows. The collection feeds items to the engine. On the right, the engine calls onValueChange on the team's state or the URL, which passes the value prop back, and the form bridge submits the value with the form.options APIGET /warehousesoptions cachekeyed by sourceCollectionflat items, keysuserclick, ArrowDownEnginenext stateViewsre-render 2 rowsteam stateor the URLform submitFormDataJSONitemskeydownstateonValueChangevalue propsubmit
Reads flow down the left, input flows down the middle, and the value leaves to the right. The engine never fetches and never stores the value itself when controlled: it asks with onValueChange and waits for the new value.
Choosing Japan with the keyboard. The user presses ArrowDown on the trigger. The trigger's handler tells the engine to open, and the engine renders the listbox with Canada active. The user types j and a. The engine finds Japan and the trigger's activedescendant is set to opt-jp, so the screen reader says Japan. The user presses Enter. The trigger asks the engine to select Japan and close. The engine calls onValueChange, the team's state updates, and the trigger re-renders showing Japan.usertriggerenginelistboxArrowDownopen()render listtypes j, atypeahead(ja)active = opt-jpEnterselect(jp), closeonValueChange(jp)
DOM focus never leaves the trigger. Every key goes to one handler, and the only thing that moves is the 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 activeKey lives in the root and every option reads it from context, a key press re-renders all 250 options. Instead, set a data-active attribute 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 one requestAnimationFrame, 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).compare puts "Österreich" where German readers expect it. A plain sort() 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-end already 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

LayerWhat it catchesTool
Engine unit testsEvery key in every state, typeahead timing (with fake timers), disabled skipping, the close path.Vitest or Jest, no DOM.
Component testsRoles, names, aria-expanded and aria-activedescendant flips, form submission, reset.Testing Library queries by role, plus axe for automatic rule checks.
Browser testsFlip 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 passesWhat 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.

MinutesWhat you doWhat to say out loud
0 to 5Ask 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 10Native 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 18The API: compound components, controlled and uncontrolled, groups, disabled, multi, async."Values are stable keys, never labels."
18 to 28Draw 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 36Positioning: 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 42Long lists, async races, forms, phones."With virtualization, the active option must be rendered before I point at it."
42 to 45Architecture 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?

Think first

"Add sub-menus to the actions menu." What do you need to handle?

Think first

"Why not just use the native select everywhere?"

Think first

"How do you know it's accessible?"

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-activedescendant points 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-index fights 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-setsize and aria-posinset. Abort stale async loads.
  • In forms, bridge the value with a hidden input or ElementInternals, and do required and 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.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next