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.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
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.
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.
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.
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.
- 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.
- 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.
- 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.
- Option row30px, 10px padding
Denser than a form control because it is transient and scanned as a list. Two-line rows go to 44px.
- Max height256px, ~8 rows
Enough to establish that the list scrolls, short enough that the popover does not cover the field it belongs to.
- 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.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used 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
| Token | Value | Used for |
|---|---|---|
| option padding | Option rows | |
| popover offset | Gap between trigger and panel |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-md | Trigger corners | |
| --radius-lg | Popover corners | |
| --radius-sm | Option row corners — inner radius rule |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e4 | — | Popover elevation |
Motion
| Token | Value | Used for |
|---|---|---|
| scale-in | Popover entrance, origin top |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Radius | Icon | Type | Min width | Max width | When to use |
|---|---|---|---|---|---|---|---|---|
| Small | 32px | 0 10px | 8px | 14px | 13px | 120px | — | Table filters, toolbars, inline controls. |
| Medium | 36px | 0 12px | 8px | 15px | 15px | 160px | 32rem | The default for every form. |
| Large | 44px | 0 14px | 12px | 17px | 17px | 200px | — | Mobile and touch-first forms. |
| Popover | 256px max | 4px | 12px | — | — | — | — | About eight single-line rows before it scrolls. |
| Option row | 30px / 44px | 6px 10px | 6px | — | — | — | — | 30px for a single line, 44px when a description is present. |
Three options, hidden.
Not a checklist to run at the end. These are the requirements the component was built from.
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 / End | First and last option. |
| Enter | Selects the active option, closes, and returns focus to the trigger. |
| Esc | Closes without changing the value. |
| Tab | Closes the list and moves on. It never traps. |
| a–z | Type-ahead. Native selects do this for free; a custom listbox must implement it. |
| Backspace | On 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.
| Attribute | Applied to | Notes |
|---|---|---|
| role="combobox" | The trigger | Plus aria-expanded, aria-haspopup="listbox" and aria-controls pointing at the panel. |
| role="listbox" | The panel | aria-multiselectable when more than one value is allowed. |
| role="option" + aria-selected | Each row | aria-selected is the selection state, not the keyboard-active state. |
| aria-activedescendant | The trigger or input | Points at the highlighted row id. Focus stays on the input, which is what keeps type-ahead working. |
| aria-autocomplete="list" | A combobox input | Tells assistive tech that suggestions appear as the user types. |
| aria-live="polite" | A result-count region | Announces "12 results" after filtering. Without it a screen-reader user has no idea the list changed. |
Example usage
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.
<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
| Prop | Type | Default | Description |
|---|---|---|---|
| options* | Option[] | — | { value, label, description?, icon?, group?, disabled? } |
| value* | string | null | — | Controlled value. null renders the placeholder. |
| onChange* | (v: string) => void | — | Fired on selection. |
| searchable | boolean | false | Adds a filter input. Turn it on past ~12 options. |
| size | 'sm' | 'md' | 'lg' | 'md' | Matches the Input and Button scale. |
| emptyText | string | 'No results' | Shown when the filter matches nothing. |
MultiSelect
| Prop | Type | Default | Description |
|---|---|---|---|
| values* | string[] | — | Controlled selection. |
| maxVisible | number | 3 | Chips shown before collapsing to "+N more". |
Combobox
| Prop | Type | Default | Description |
|---|---|---|---|
| onQueryChange | (q: string) => void | — | Enables async mode: the component stops filtering locally. |
| loading | boolean | — | Swaps the chevron for a spinner in place, so nothing reflows. |
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.