User Experience (UX)
Now let's look at how a select behaves for every person, input and setting. A good select is accessible, predictable, designed in every state and adaptive.
Accessible
Use a button with the combobox role
We recommend the APG's select-only combobox, built on a <button> rather than the <div> its example uses. A plain button can already carry aria-expanded, but it has no value of its own for assistive technologies to read, and a form control needs one. The combobox role gives it one: the text inside it. Its label stays its name (WCAG 4.1.2), as the accessibility tree shows.
We use a <button> so the Field can name it. A <div role="combobox"> isn't labelable, so a <label for> can't name it. A <button> is, and ARIA in HTML allows the role on it, so the Field names the Select with the same <label for> it gives an Input (Radix Primitives builds its trigger the same way).
The panel is a listbox of options, with aria-selected on each, and aria-multiselectable when multiple is on. combobox, aria-expanded, aria-controls and aria-activedescendant link the two. Group headings use role="group" with aria-labelledby, and a separator gets role="none", since a listbox doesn't allow one as a child.
Name every select
Every Select needs a name, and the simplest way is to put it in a Field, which gives it a <label for>. Where there's no visible label, like a filter in a toolbar, give it an aria-label (WCAG 3.3.2). Screen readers read the name, then the value, and the panel takes the same name, so the list has a name too. Our reference implementation warns you during development if there's no name.
Let a press on the label focus it, not open it
A press on a <label for> clicks its control, and for a button that would open the panel. A native select only takes focus, so the Select does the same. Keep in mind that detail can't tell the two clicks apart, since a screen reader's activation has a detail of 0 as well, so it's the click on the label itself you'll want to catch.
Keep focus on the control
Focus never moves into the panel. aria-activedescendant on the control says which option is highlighted, and screen readers follow it (WCAG 2.4.3). That's the one place the Select differs from the Popover, which moves focus in: the Popover holds controls people tab between, while typing a letter or pressing an arrow here has to keep working on the control. It also means there's nothing to give focus back to when the panel closes, so it stays where it was.
Announce the value
A combobox reads its value when focus lands on it, so a single value needs nothing more. With several, the tags sit outside the button, so the button holds their labels as visually hidden text, and past three, a count like "4 selected". Removing a value is announced through one polite live region, since a tag vanishing is otherwise silent (WCAG 4.1.3). As people move through the options, aria-selected on each one tells them which are picked.
Contrast and size
Text, borders and the focus ring follow the rules on the UI page (WCAG 1.4.3, WCAG 1.4.11, WCAG 2.4.7). Press targets are at least 24 × 24px (WCAG 2.5.8), and on touch screens at least 44px tall (WCAG 2.5.5).
Predictable
Keyboard
The Select follows the APG's keyboard model, so people who know one native select or one combobox already know this one (WCAG 2.1.1, WCAG 2.1.2). A closed Select:
| Key | What it does |
|---|---|
ArrowDown, Alt + ArrowDown | Opens it, without moving the highlight or changing the value |
Enter, Space | Opens it, without moving the highlight or changing the value |
ArrowUp, Home | Opens it and highlights the first option |
End | Opens it and highlights the last option |
| A letter | Opens it and highlights the option that starts with that letter |
Backspace | With multiple, removes the last value |
With the panel open:
| Key | What it does |
|---|---|
ArrowDown, ArrowUp | Moves the highlight, skipping disabled options, and stops at the ends |
Home, End | Moves to the first or the last option |
PageDown, PageUp | Moves ten options |
| A letter | Moves to the next option that starts with what people typed |
Enter, Space | Picks the highlighted option and closes. With multiple, toggles it and stays open |
Alt + ArrowUp | Picks the highlighted option and closes. With multiple, closes and keeps what's toggled |
Tab | Picks the highlighted option, closes and moves on. With multiple, closes and keeps what's toggled |
Escape | Closes it, without changing the value, and goes no further |
Tab taking the highlighted option comes straight from the APG, though native selects differ from one browser to the next, so we follow the one written source. Keep in mind that it means people who open the list and press Tab straight away pick the option that's highlighted, the first one when nothing is picked yet.
Type to find
Typing moves to the option that starts with what people typed, as in the APG's example. Letters typed in quick succession search as one string, spaces included, so "new z" finds New Zealand, and pressing one letter again moves through the options that start with it. Disabled options are skipped. We forget what people typed after 500ms, which we recommend since it's long enough to type a word and short enough to start over.
Remove a value by pointer and by keyboard
Each tag's remove button is a real <button>, named "Remove France", which Tab reaches after the control, since it comes after the control in the DOM (WCAG 1.3.2, WCAG 2.4.3). Backspace on the closed control removes the last value, and focus stays put. When a remove button takes itself away, focus moves to the next remove button, or back to the control when no remove button comes after it, so keyboard users never drop to the top of the page.
Close it every way people expect
Escape, a press outside and picking a single value all close the panel. The panel uses popover="auto", so the browser closes it on a press outside, and it has the Popover's answer to Escape inside other layers: the Select cancels the key, so a Dialog around it stays open.
Render it inside a Dialog
Render the Select inside the Dialog, so the Dialog doesn't make it inert, and the panel goes in the top layer on top of it. Escape closes the panel and stops, so the Dialog stays open, and Escape again closes the Dialog.
<Dialog id="address" title="Edit address" open={open} onClose={() => setOpen(false)}>
<Field label="Country">
<Select id="country" name="country" options={countries} defaultValue="fr" nativeOnTouch={false} />
</Field>
</Dialog>
Let the form do its work
A native <select> is always in the page, and it does the form's work. In the custom rendering it's see-through and out of the tab order, and in the native rendering it's the control. So the Select submits under its name, several values included, required works, and a reset goes back to the first value, in either rendering, with no script of your own.
<form onSubmit={send}>
<Field label="Countries" required>
<Select id="countries" name="countries" multiple options={countries} defaultValue={["fr"]} nativeOnTouch={false} />
</Field>
<Button type="submit" color="primary">Send</Button>
<Button type="reset" variant="outline">Reset</Button>
</form>
Three things make that hold, and each is easy to get wrong:
- It's never
display: none, which makes a required select unfocusable and blocks submit in silence. If the browser focuses it to report a problem, focus moves to the control in that same event handler, since Chromium hides its validation bubble on any later change of focus. - A selection in the panel fires a bubbling
changefrom the<select>, so form-level listeners and form libraries hear it. - A form's reset goes back to each option's default selectedness, which only the
selectedattribute sets, so the first value stays written out as that attribute however the framework renders.
Don't pick an option for people
A single Select always starts with a first <option value="" disabled hidden>, holding the placeholder or empty text, selected while there's no value. It's the HTML Standard's placeholder label option, so it counts as missing when required, and it's disabled, so it submits nothing and a native picker can't choose it either.
So with no value and no placeholder, the field starts blank, the value stays unset and nothing is submitted. A bare <select> would pick its first option there, and we depart from it on purpose: people would send an option they never looked at, and your value would disagree with the form. The GOV.UK Design System makes the same call for questions, for fear of swaying the answer. We recommend a placeholder or a first value either way. To let people clear a single value, add an option with an empty value to your options.
Every state
Closed
The field shows the value, or the placeholder at 4.5:1. The panel stays in the page while it's closed, so its options are ready, but it isn't in the accessibility tree.
Open
aria-expanded is "true", the arrow turns and one option is highlighted, the selected one or else the first that isn't disabled. It scrolls into view as the highlight moves, since browsers only do that for elements with real focus.
Disabled
disabled goes on both controls, so it can't be focused and leaves its form, and the Field dims its label. Tags lose their remove buttons. We recommend disabling as little as you can, since a disabled Select can't say why (WCAG 3.3.1).
Read-only
The value stays, in its form, at full contrast with a dashed border. Nothing opens and tags can't be removed, and aria-readonly says so. A native <select> has no read-only state, so readonly turns off nativeOnTouch.
Invalid
aria-invalid and the error's ID on aria-describedby go on the active control, set by the Field, and the border turns to the error colour (WCAG 3.3.1).
Long and empty
A long value is cut off with an ellipsis in the field, and an option's text wraps in the panel, so nothing is lost where people pick. An empty list opens an empty panel, so we recommend not rendering the Select until it has options.
Adaptive
Native on touch
nativeOnTouch shows the native <select> where the primary pointer is coarse, so a phone or a tablet gets its own picker. It's on by default for two reasons: the APG warns that its pattern has support gaps on touch devices, and the native picker is what phone screen reader users already know.
<Field label="Country">
<Select id="country" name="country" options={countries} placeholder="Choose a country" />
</Field>
"Coarse" is (pointer: coarse), for the primary pointer, rather than a viewport width, since a narrow desktop window still has a mouse. It's not the Input's any-pointer: coarse either: a laptop with a touch screen has a fine primary pointer, so it keeps the custom panel, while the Input's touch sizing applies as soon as any pointer is coarse.
Both controls are in the markup from the start, so the server and the first render agree whatever the device. CSS alone chooses what paints, with a @media (pointer: coarse) rule on the same query, so there's no hydration mismatch and no layout shift. After mount, a script moves the id, the ARIA attributes and data-slot="control" to whichever control is active, along with the tabindex and aria-hidden that only the hidden one has, and it listens for the pointer changing. So the value, the name, the form participation, the validation and the Field wiring are the same in both, as the tests check.
The root's tokens, and the leading and arrow slots, stay in both renderings, since they're children of the field. Here's what the native rendering can't honour, as the browser draws these itself:
- The
valueslot and its token, as the browser paints the<select>'s own text. - Tags, the
tagslot and its tokens. - The panel and its tokens.
- The
option,group-labelandiconslots and their tokens, and the check mark. - The
separatorslot and its tokens. Browsers draw an<hr>between options in their own way, and older ones leave it out.
If those have to look like your brand on phones too, turn the fallback off with nativeOnTouch set to false, and test with the phones you support.
Pointer, touch and pen
- Hover styles only apply to pointers that can actually hover (
@media (hover: hover)), otherwise a tap would leave the field or an option looking hovered. - On touch screens, the field and each remove button reach past what you see to a hit area at least 44px tall, and each option is at least 44px tall (WCAG 2.5.5).
- On touch screens, the text is at least 16px, whatever the size, since Safari on iOS zooms in when a control with smaller text gets focus, as with the Input.
Small screens
The panel is as wide as the field, never wider than the screen less 32px, and never taller than 16rem or the screen less 32px, and it scrolls inside that. It flips above the field when there's no room below, and when neither side has room, it shrinks on the bigger one, as the Popover does (WCAG 1.4.10). Browsers without CSS anchor positioning (Chrome and Edge before 129, Firefox before 147 and Safari before 26) get a script that does the same.
Motion
The arrow turns and the panel fades in over 150ms, unless people have asked for less motion (WCAG 2.3.3).
More contrast
When someone asks for more contrast (prefers-contrast: more), as with Increase contrast on macOS and iOS, the borders of the field and the panel are drawn in the text colour at full strength, rather than at 55% and 20%.
Forced colours
Windows contrast themes replace every colour with the user's own, and drop backgrounds and shadows. The focus ring, the highlighted option and a focused remove button are drawn with Highlight, the panel and each tag get a border in CanvasText, and a disabled field or option uses GrayText.
Colour scheme
The panel's background is Canvas, and the text reads --color--neutral-text, which uses light-dark(). The borders, tags and highlights mix that text colour, so the Select follows color-scheme and adapts to your light and dark themes on its own, as long as they set it.
Text size and spacing
Heights are minimums in rem, and tags wrap onto more lines, so the field grows with its text instead of clipping it (WCAG 1.4.4). A long value ends in an ellipsis, while options wrap, as Long and empty explains.
Languages and direction
The labels for removing and for the count come through removeLabel, removedLabel and countLabel, so they can be translated, and the Select uses logical properties throughout, so it mirrors in right-to-left layouts.
UX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
select/combobox-pattern | Must | Component | The control is a <button role="combobox"> linked to a listbox with aria-expanded, aria-controls and aria-activedescendant | WAI-ARIA, APG, ARIA in HTML | Unit test, ARIA snapshot |
select/has-a-name | Must | Both | It has a name, from a Field's label or an aria-label, and the panel has the same name | WCAG 4.1.2 (A), 3.3.2 (A) | Unit test |
select/reads-its-value | Must | Component | The control's text is its value, as a label, a list of labels or a count past three | WCAG 4.1.2 (A) | Unit test, ARIA snapshot |
select/label-press-focuses | Must | Component | A press on its label gives it focus without opening the panel | HTML, Open Components | Unit test |
select/keyboard | Must | Component | Every key in the keyboard tables does what they say | WCAG 2.1.1 (A), APG | Unit test, Keyboard |
select/typeahead | Must | Component | Letters typed in quick succession search the enabled options, spaces included, and one letter repeated cycles through them | APG | Unit test |
select/focus-stays | Must | Component | DOM focus stays on the control, and aria-activedescendant follows the highlighted option | APG, WCAG 2.4.3 (A) | Unit test |
select/tab-commits | Should | Component | Tab picks the highlighted single option, or keeps what's toggled, and moves on | APG | Unit test |
select/escape | Must | Component | Escape closes the panel without changing the value and stops there | Open Components, APG | Unit test |
select/light-dismiss | Must | Component | A press outside closes it, and a press on the control while it's open doesn't reopen it | HTML | Unit test |
select/remove-tag | Must | Component | A value can be removed by pointer and by keyboard, with Backspace on the closed control too | WCAG 2.1.1 (A) | Unit test |
select/focus-after-remove | Must | Component | Focus goes to the next remove button, or the control when there's none after it | WCAG 2.4.3 (A) | Unit test |
select/announce-removal | Should | Component | A removed value is announced through a polite live region | WCAG 4.1.3 (AA) | Unit test, Screen reader |
select/form-submit | Must | Component | It submits under its name in both renderings, several values included | HTML | Unit test |
select/form-reset | Must | Component | A form's reset returns it to its first value, or to nothing, in both renderings | HTML | Unit test |
select/required | Must | Component | required reports a missing value in both renderings, and focus reaches the control | HTML | Unit test |
select/validation-focus | Must | Component | Focus on the hidden <select> moves to the control in the same handler | HTML, Open Components | Unit test |
select/no-auto-pick | Must | Component | With no value it starts blank, submits nothing and fires nothing on mount | Open Components, beyond HTML | Unit test |
select/change-event | Must | Component | A selection in the panel fires a bubbling change from the <select> | HTML | Unit test |
select/disabled | Must | Component | disabled goes on both controls, removes the tags' buttons and leaves its form | HTML | Unit test |
select/read-only | Must | Component | readonly keeps the value in its form, opens nothing and turns off the fallback | Open Components | Unit test |
select/native-fallback | Must | Component | nativeOnTouch shows the <select> on a coarse primary pointer, with the same value, form participation, validation and Field wiring | HTML, Open Components | Unit test, Emulation |
select/no-hydration-mismatch | Must | Component | Both renderings are in the markup, CSS chooses what paints and the script moves only attributes, so there's no mismatch or shift | Open Components | Unit test, Emulation |
select/in-a-dialog | Must | Both | Rendered inside a Dialog, its panel opens on top and Escape closes only the panel | HTML, Open Components | Unit test |
select/panel-fits | Must | Component | The panel flips and shrinks to stay inside the screen, down to 320px wide | WCAG 1.4.10 (AA) | Emulation, 200% zoom |
select/touch-text-size | Should | Component | On touch screens its text is at least 16px, so Safari on iOS doesn't zoom in | Open Components | Emulation |
select/reduced-motion | Should | Component | The arrow and the panel don't animate for people who asked for less motion | WCAG 2.3.3 (AAA) | Emulation |
select/forced-colors | Must | Component | Focus, the highlight, tags and disabled options stay visible in forced colours mode | Open Components | Emulation |
select/right-component | Should | Usage | It's for picking out of many, while a few options use radios and a few with several answers use checkboxes | Open Components | Review |
FAQ
Select or something else?
We recommend showing options on the page whenever they fit, because people can compare them without opening anything. The GOV.UK Design System goes further and calls a select a last resort in public-facing services, as research shows some people find them very hard to use. So try to ask for fewer options first, and reach for the Select when there are too many to lay out.
| When you need to… | Use |
|---|---|
| Pick one option out of a few, which fit on the screen | Radio Group |
| Pick any number of options out of a few | Checkbox Group |
| Pick one option, or several, out of many | Select |
| Type to filter a long list | Combobox |
| Run a few actions behind one button | Dropdown Menu |
The Combobox draws its panel, options, highlight and tags with the Select's own code and tokens, with a text field for typing to filter. The Dropdown Menu, on our Roadmap, will reuse its placement, typeahead and highlight, with menu and menuitem roles and no value of its own.
appearance: base-select lets you restyle a real <select>, and it's in Chrome 135 and Safari 27, but not in Firefox, where people would get an unstyled select. MDN also notes that it stops a select from using the phone's own picker, which is the one thing we want on phones. Its support for selecting several values is tracked separately and is narrower still.Some of what a native <select> has for free. We earn back the form's work with the <select> underneath and the phone's picker with nativeOnTouch. Screen reader support is the part you can't buy back with code: a native select maps to a combobox on Windows and a popup button on macOS, iOS and Android, and a custom one has to pick one role for all of them, so test with the assistive technologies your users have. React Aria keeps a hidden native select for the same reason as ours.
If your Select doesn't need the panel or the tags, we recommend a plain <select> in a Field, which needs none of this.
<select> needs real <option> elements in the server's markup, which a child component can't render into two trees at once. That's why it takes a prop, while the Radio Group takes its options as children.options, like "None".