Skip to content

Select

Four different controls that all look like a box with a chevron. Picking the wrong one is the most common form mistake there is.

Also called Dropdown, Listbox, Picker, Native Select — in this system all of them are Select.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

Latency is measured from your current location.

The four kinds

Same data, four controls. The right choice depends on list size, whether rows need structure, and whether more than one value is allowed.

Under ~15 flat options. Always the first choice.

Rich rows: icons, descriptions, groups.

Selections stay visible as removable chips.

Type to filter. Best past ~20 options.

Asynchronous options

Debounced at 320ms, previous results stay visible while loading, and the result count is announced. Blanking the list on every keystroke is what makes async pickers feel broken.

Type at least one character.

Grouped and searchable

Group headings are presentational — they are not selectable and are skipped by arrow keys. Search filters across the label and the description.

Interaction states

Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.

Maintainer▾
Closed
Choose a role▾
Placeholder
Maintainer
Focus
Maintainer
Disabled
Choose a role
Error
Multi
Searching…
Loading
No results
Empty

Anatomy

Every part, every measurement, and the reason it is that number.

Trigger and popover. The popover matches the trigger width so the eye does not have to re-anchor when it opens.

  1. Trigger height36px

    Identical to a text input, so a select and an input on the same row share a baseline. This is the whole reason the control scale is shared.

  2. Chevron gutter36px right padding

    A 16px chevron plus a 12px gutter on each side. Less than this and long option labels collide with the icon.

  3. Popover offset6px below the trigger

    Close enough to read as attached, far enough that the trigger’s focus ring is not clipped by the panel.

  4. Option row30px, 10px padding

    Denser than a form control because it is transient and scanned as a list. Two-line rows go to 44px.

  5. Max height256px, ~8 rows

    Enough to establish that the list scrolls, short enough that the popover does not cover the field it belongs to.

  6. Selected markerCheck, right aligned

    A check, not just a highlight — highlight is used for the keyboard-active row, and the two states must be distinguishable.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-surface-inset—Trigger background
--ds-surface-overlay—Popover background
--ds-layer-hover—Active option row
--ds-accent—Selected check, focus border
--ds-accent-subtle—Focus halo, selected chip fill
--ds-fg-muted—Placeholder, chevron, descriptions

Spacing

TokenValueUsed for
option paddingOption rows
popover offsetGap between trigger and panel

Radius

TokenValueUsed for
--radius-mdTrigger corners
--radius-lgPopover corners
--radius-smOption row corners — inner radius rule

Shadow

TokenValueUsed for
--shadow-e4—Popover elevation

Motion

TokenValueUsed for
scale-inPopover entrance, origin top

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingRadiusIconTypeMin widthMax widthWhen to use
Small32px0 10px8px14px13px120px—Table filters, toolbars, inline controls.
Medium36px0 12px8px15px15px160px32remThe default for every form.
Large44px0 14px12px17px17px200px—Mobile and touch-first forms.
Popover256px max4px12px————About eight single-line rows before it scrolls.
Option row30px / 44px6px 10px6px————30px for a single line, 44px when a description is present.

Do

Start native, upgrade only when you need toThe native select gets keyboard, type-ahead, mobile wheel pickers and form participation for free. Every one of those is something a custom listbox has to earn back.
Add search once the list passes about twelve optionsScanning is fast up to roughly ten items and slow after that. A filter input turns a linear scan into a single recognition step.
Keep multi-select choices visibleChips inside the control mean the current state is readable without opening anything, and each one is individually removable. A "3 selected" summary makes the user open the list to find out which three.
Match the popover width to the triggerThe eye is already anchored to the trigger’s left edge. A popover that is wider or narrower forces a re-anchor on every open, which is a small cost paid many times.

Don't

Three options, hidden.

Do not use a dropdown for three optionsA select hides two options behind a click to save about 60px. Radios show everything, are one click instead of two, and are scannable at a glance.
Do not put actions in a selectA select sets a value; a menu performs an action. Choosing "Delete" from a dropdown that then stays showing "Delete" is genuinely confusing.
Loading…
Do not clear the list while loadingBlanking the results on every keystroke makes the control flicker and feel broken on a slow connection. Keep the previous results and dim them.
Option 1 of 412
Option 2 of 412
Option 3 of 412
Option 4 of 412
Option 5 of 412
Option 6 of 412
Option 7 of 412
Do not open a listbox with 400 unfiltered rowsA scroll list of hundreds is not a choice, it is a haystack. Past about twenty options the control must have search, and past a few hundred it must be server-side.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA2.1.1KeyboardA2.4.3Focus OrderA4.1.2Name, Role, ValueA

Contrast

  • The trigger border must reach 3:1 against the page — it is the boundary of a control.
  • The active-row highlight must be distinguishable from the selected-row check. Two rows in different states that look identical is a real usability failure, not a nitpick.
  • Disabled options at 40% opacity are exempt from contrast, but they must still be visually distinct from enabled ones.

Keyboard

Space / Enter / ↓Opens the list from the closed trigger.
↑ / ↓Moves the active option. Skips disabled options and group headings.
Home / EndFirst and last option.
EnterSelects the active option, closes, and returns focus to the trigger.
EscCloses without changing the value.
TabCloses the list and moves on. It never traps.
a–zType-ahead. Native selects do this for free; a custom listbox must implement it.
BackspaceOn a multi-select with an empty query, removes the last chip.

Screen readers

  • Group headings must be presentational, not options. A screen reader that announces "Europe, option 4 of 9" when Europe is a heading is a broken experience.
  • Announce the result count after filtering, debounced. Announcing on every keystroke floods the queue.
  • A native select announces its value, role and position in the set automatically. That is a lot of behaviour to give up.

Focus & touch

  • Focus stays on the trigger or the input for the entire interaction. Moving focus into the list breaks type-ahead and makes Escape ambiguous.
  • Option rows are 44px on coarse pointers. Native selects should be preferred on mobile wherever possible — the OS picker is faster and more familiar than any custom sheet.
AttributeApplied toNotes
role="combobox"The triggerPlus aria-expanded, aria-haspopup="listbox" and aria-controls pointing at the panel.
role="listbox"The panelaria-multiselectable when more than one value is allowed.
role="option" + aria-selectedEach rowaria-selected is the selection state, not the keyboard-active state.
aria-activedescendantThe trigger or inputPoints at the highlighted row id. Focus stays on the input, which is what keeps type-ahead working.
aria-autocomplete="list"A combobox inputTells assistive tech that suggestions appear as the user types.
aria-live="polite"A result-count regionAnnounces "12 results" after filtering. Without it a screen-reader user has no idea the list changed.

Code

Example usage

tsx
1import { NativeSelect, Select, MultiSelect, Combobox } from '@/ui/Select'23// 1. Default. Under ~15 flat options.4<NativeSelect5  options={roles}6  value={role}7  onChange={(e) => setRole(e.target.value)}8  placeholder="Choose a role"9/>1011// 2. Rich rows — icons, descriptions, groups12<Select13  options={regions}          // { value, label, description?, icon?, group? }14  value={region}15  onChange={setRegion}16  searchable={regions.length > 12}17  aria-label="Deployment region"18/>1920// 3. Multiple values, visible as removable chips21<MultiSelect options={tags} values={selected} onChange={setSelected} maxVisible={3} />2223// 4. Async. Debounce, and keep the old results while loading.24const [results, setResults] = useState<Option[]>([])25const [loading, setLoading] = useState(false)2627const search = useMemo(28  () =>29    debounce(async (q: string) => {30      if (!q) return setResults([])31      setLoading(true)32      try {33        setResults(await api.search(q))   // do NOT clear results first34      } finally {35        setLoading(false)36      }37    }, 320),38  [],39)4041<Combobox42  options={results}43  value={value}44  onChange={setValue}45  onQueryChange={search}46  loading={loading}47/>

Framework-free HTML

The native version. Styleable, and everything below works with no JavaScript.

html
<label class="ds-field__label" for="region">Deployment region</label>

<div class="ds-select">
  <select class="ds-select__control" id="region" name="region">
    <option value="" disabled selected>Choose a region</option>
    <optgroup label="Europe">
      <option value="eu-west-2">Europe (London)</option>
      <option value="eu-central-1">Europe (Frankfurt)</option>
    </optgroup>
    <optgroup label="Americas">
      <option value="us-east-1">US East (N. Virginia)</option>
    </optgroup>
  </select>
  <svg class="ds-select__chevron" aria-hidden="true">…</svg>
</div>

<!-- Custom listbox, if you genuinely need one -->
<button
  role="combobox"
  aria-expanded="false"
  aria-haspopup="listbox"
  aria-controls="region-list"
  aria-activedescendant="region-opt-2"
>Europe (London)</button>

<div role="listbox" id="region-list">
  <div role="option" id="region-opt-2" aria-selected="true">Europe (London)</div>
</div>

Component API

Select

PropTypeDefaultDescription
options*Option[]—{ value, label, description?, icon?, group?, disabled? }
value*string | null—Controlled value. null renders the placeholder.
onChange*(v: string) => void—Fired on selection.
searchablebooleanfalseAdds a filter input. Turn it on past ~12 options.
size'sm' | 'md' | 'lg''md'Matches the Input and Button scale.
emptyTextstring'No results'Shown when the filter matches nothing.

MultiSelect

PropTypeDefaultDescription
values*string[]—Controlled selection.
maxVisiblenumber3Chips shown before collapsing to "+N more".

Combobox

PropTypeDefaultDescription
onQueryChange(q: string) => void—Enables async mode: the component stops filtering locally.
loadingboolean—Swaps the chevron for a spinner in place, so nothing reflows.

Notes

Professional tips

  • Sort by likelihood, not alphabetically, when there is an obvious front-runner. "United States" at the top of a country list saves far more time than strict A–Z costs.
  • A dropdown near the bottom of the viewport should open upward. Flipping is table stakes; a popover that opens off-screen is a dead control.
  • For a country or timezone picker, always use a combobox. Nobody scrolls to Zimbabwe.
  • Set a sensible default rather than a placeholder wherever one exists. A pre-filled correct answer is faster than any picker.

Performance

  • Virtualise past about 200 options. Rendering 5,000 DOM nodes into a popover blocks the main thread for hundreds of milliseconds on a mid-range device.
  • Debounce async search by 300–500ms and cancel in-flight requests with an AbortController — otherwise a slow early response can overwrite a fast later one.
  • Memoise the filtered list. Recomputing a filter over thousands of options on every keystroke is a common source of input lag.
  • Render the popover only when open. Keeping a hidden list of 500 rows mounted costs memory and slows every parent re-render.

Common mistakes

  • Rebuilding a native select purely for visual consistency, then shipping something with no type-ahead and broken arrow keys.
  • Making group headings selectable, so arrow keys stop on them and screen readers announce them as options.
  • Closing the popover on scroll instead of repositioning it. The user scrolls slightly to see the list and it vanishes.
  • Forgetting to reset the search query when the popover reopens, so the user sees a stale filtered list.
  • Not announcing the result count. Sighted users see the list shrink; screen-reader users get silence.

Real-world recommendations

  • Measure how often each option is selected. A long tail with one dominant answer means the default is wrong, not that the list needs better search.
  • On mobile, the native select opens the OS picker, which is faster and more familiar than any custom sheet. Do not replace it without a genuine reason.
  • For a multi-select that regularly exceeds ten values, consider a different pattern entirely — a two-pane transfer list or a dedicated management screen.
  • When a dropdown consistently causes support tickets, the fix is usually clearer option labels rather than a better dropdown.