User Interface (UI)
Let's start with how a select looks: its parts, one or several values, groups, sizes, its states and the variables that restyle each part.
Anatomy
- Trigger — The field itself, a
<div>that draws the border, the background and the focus ring, and holds the parts below. - Leading icon — Optional. An icon before the value, hidden from assistive technologies.
- Icon — Optional. An icon for each option, shown in the panel, beside the picked value and in each tag, hidden from assistive technologies.
- Tag — With
multiple, one per value, with its own remove button. Tags sit beside the button, inside the field. - Value — The picked option's label, or the placeholder, inside the
<button>that has thecomboboxrole. - Arrow — The chevron, which turns as the panel opens, hidden from assistive technologies.
- Panel — The floating listbox, with its own scroll boundary, in the top layer while it's open.
- Group label — Optional. A heading for a group of options.
- Option — One choice, with its hover, highlighted, selected and disabled states and a check mark when it's picked.
- Separator — Optional. A rule between options or groups.
The trigger is the root, and it isn't the button. That's because a <button> can't contain other interactive content, and each tag holds a remove button. So the root draws the field the way the Input's does, and the <button> fills it, beneath the tags and icons, so a press anywhere on the field reaches it. You can see the order of the parts in the DOM contract.
One value or several
A Select picks one value by default, and shows its label in the field. With multiple, it picks any number, and each one becomes a tag in the field, which grows onto more lines as tags wrap. The panel stays open while people toggle options, since they're likely to want more than one.
<Field label="Countries">
<Select id="country" name="country" multiple options={countries} placeholder="Choose countries" nativeOnTouch={false} />
</Field>
Each tag holds a real <button> to remove it, and the control holds the values as visually hidden text, so the combobox still has a value for a screen reader to read. Past three values, it reads a count instead, since nobody wants a long list read out every time focus lands on the field.
We recommend tags over a line like "3 selected", because people can see what they picked and remove one without opening the list.
Groups and separators
Options can sit in groups, each with a heading, and a separator can go between groups or options. We recommend groups once a list passes about ten options, because a heading lets people skip the ones that don't apply.
const destinations = [
{ label: "Europe", options: [{ value: "fr", label: "France" }, { value: "es", label: "Spain" }] },
{ separator: true },
{ label: "Oceania", options: [{ value: "nz", label: "New Zealand" }, { value: "au", label: "Australia", disabled: true }] },
];
<Select id="destination" name="destination" options={destinations} placeholder="Choose a destination" nativeOnTouch={false} />
Groups are for reading, so the heading can't be picked, and a disabled option stays in the list, so people can see it exists, without being picked or matched by typing.
Icons
An option can have an icon, like a label's symbol or a country's flag, which helps people scan a long list and recognise what they picked. The icon slot gets each option, and the Select shows what it returns before the option's label in the panel, before the value once it's picked and in each tag, so a picked value looks the same everywhere.
const icons = { bug: BugIcon, feature: SparklesIcon, docs: BookOpenIcon, question: CircleHelpIcon };
<Field label="Labels">
<Select id="labels" name="labels" multiple options={labels} nativeOnTouch={false}
icon={({ option }) => {
const Icon = icons[option.value];
return <Icon />;
}}
/>
</Field>
Icons are decoration, so they're hidden from assistive technologies, and the label alone names each option (WCAG 1.1.1). Keep it that way, since an icon that means something the label doesn't say is lost on anyone who can't see it. The icon comes through a slot rather than a field in options, so it works with whatever icon library you use, and the options stay plain data you can send from a server. The native rendering draws text only, so it shows no icons.
Sizes
A Select has the Input's five sizes, with the same heights and the same radius, so a Select, an Input and a Button line up in a row. We recommend size over setting a height, since it scales the padding, the text, the arrow and the tags together.
<Select size="xs" options={countries} id="size-xs" aria-label="Country" />
<Select size="sm" options={countries} id="size-sm" aria-label="Country" />
{/* …md, lg and xl */}
| Size | Height | Font size | Padding | Tag height |
|---|---|---|---|---|
xs | 24px | 12px | 8px | 18px |
sm | 32px | 14px | 10px | 20px |
md | 36px | 14px | 12px | 24px |
lg | 40px | 16px | 14px | 28px |
xl | 48px | 16px | 16px | 32px |
States
Each state has a look of its own, and reads the attribute that exposes it, so what people see and what assistive technologies report can't drift apart.
Pick a country we ship to.
<Field label="Disabled" disabled>
<Select id="country" name="country" options={countries} defaultValue="fr" />
</Field>
<Field label="Read-only">
<Select id="plan" name="plan" options={countries} defaultValue="fr" readonly />
</Field>
<Field label="Invalid" error="Pick a country we ship to.">
<Select id="ship" name="ship" options={countries} />
</Field>
| State | Selector on the active control or option | What it looks like |
|---|---|---|
| Default | none | A border at 3:1 against the page |
| Hover | :hover on the field, where a pointer can hover | A stronger border |
| Focus | :focus-visible | A 2px ring around the whole field, 2px away |
| Open | [data-state="open"] | The arrow turned, and the panel showing |
| Disabled | :disabled | Faded to 50%, and left out of its form |
| Read-only | [aria-readonly="true"] | A dashed border, with the value at full contrast |
| Invalid | [aria-invalid="true"] | An error-coloured border, 2px wide without growing |
| Highlighted | [data-active="true"], on an option | A 2px outline inside the option, as focus stays on the control |
| Selected | [aria-selected="true"], on an option | A heavier weight and a check mark |
| Disabled option | [aria-disabled="true"], on an option | Faded, and skipped by the keyboard |
A picked option has both a check mark and a heavier weight, so it doesn't rest on colour alone (WCAG 1.4.1). Keep the field at 3:1 against the page (WCAG 1.4.11) and text at 4.5:1 (WCAG 1.4.3), which the defaults reach through currentColor, so a restyle has to keep them too.
The press targets are at least 24 × 24px (WCAG 2.5.8), the remove button included, whatever size the tag is. On a touch screen, the field, each option and each remove button reach at least 44px tall, which is WCAG 2.5.5's larger target.
Tokens
The Select's look comes from variables on its root, which you can set from a class. Tokens are named as token paths: --select--… for the field, then a group per part, like --select--panel--…, and a state gets a group too, like --select--option--selected--…. They're in the components layer, so a class of yours wins without any specificity tricks, and they stay on the root, so the panel inherits them even in the top layer.
| Token | Default | Part |
|---|---|---|
--select--height | 2.25rem, per size | Trigger |
--select--padding, --select--gap | 0.75rem, 0.5rem, per size | Trigger |
--select--font-size | 0.875rem, per size | Trigger |
--select--radius | 0.5rem, per size | Trigger |
--select--border-color | The text colour at 55% | Trigger |
--select--background | transparent | Trigger |
--select--icon--size, --select--leading--color | 1rem, the text colour at 70% | Leading |
--select--value--placeholder-color | The text colour at 70% | Value |
--select--arrow--size, --select--arrow--color | 1rem, the text colour at 70% | Arrow |
--select--panel--offset, --select--panel--padding | 0.25rem | Panel |
--select--panel--max-height | 16rem | Panel |
--select--panel--radius | 0.5rem | Panel |
--select--panel--border-color | The text colour at 20% | Panel |
--select--panel--background | Canvas | Panel |
--select--panel--box-shadow | The Popover's | Panel |
--select--option--padding-block, --select--option--padding-inline | 0.375rem, 0.5rem | Option |
--select--option--radius | 0.25rem | Option |
--select--option--color | inherit | Option |
--select--option--hover--background | The text colour at 8% | Option |
--select--option--selected--background, --select--option--selected--font-weight | transparent, 600 | Option |
--select--option--disabled--opacity | 0.5 | Option |
--select--tag--height, --select--tag--padding | 1.5rem, 0.5rem, per size | Tag |
--select--tag--radius, --select--tag--font-size | 0.25rem, 0.75rem | Tag |
--select--tag--background, --select--tag--color | The text colour at 10%, inherit | Tag |
--select--tag--icon--size | 1em | Icon, in a tag |
--select--group-label--padding-block, --select--group-label--padding-inline | 0.375rem, 0.5rem | Group label |
--select--group-label--font-size, --select--group-label--font-weight | 0.75rem, 500 | Group label |
--select--group-label--color | The text colour at 70% | Group label |
--select--separator--color, --select--separator--margin | The text colour at 20%, 0.25rem | Separator |
It reads --color--neutral-text, --color--focus and --color--error-text from your theme. If you know Styleframe's Select, a few names differ here, so they match the rest of Open Components:
| Styleframe | Here | Why |
|---|---|---|
| The trigger | The root .select and its control | Tags hold buttons, which can't sit inside a <button>, so the field and the button are apart |
| The chip | tag | The name we use for a removable label |
label | group-label | The Field owns "label", so a heading in the list needs a name of its own |
invalid | aria-invalid | No other component has an invalid prop: the Input, the Field and the rest read the attribute |
| Three sizes | The Input's five | So a Select lines up with an Input and a Button |
The sections below restyle seven of the parts, each with its variables and, where it has one, its slot. The leading icon and the value restyle with the trigger, and the option icons have their own section. The native rendering honours only some of them, which Native on touch lists.
Trigger
The root draws the field, so its tokens set the border, the background and the radius. The leading slot adds an icon and the value slot changes how the picked value reads.
<Select className="brand" id="country" name="country" options={countries} nativeOnTouch={false} leading={<GlobeIcon />} renderValue={({ selected }) => <>Ship to {selected[0]?.label}</>} />
.brand {
--select--radius: 1.25rem;
--select--border-color: var(--color--primary);
--select--background: color-mix(in srgb, var(--color--primary) 8%, transparent);
}
Panel
The panel has no slot, so its tokens do the work: they set its radius, padding, border, background and shadow, and how far it sits from the field.
<Select className="brand" id="country" name="country" options={countries} nativeOnTouch={false} />
.brand {
--select--panel--radius: 1rem;
--select--panel--padding: 0.5rem;
--select--panel--border-color: var(--color--primary);
}
Option
The option slot changes what goes in an option, and the check mark stays beside it.
<Select className="brand" id="country" name="country" options={countries} nativeOnTouch={false} option={({ option }) => <>{option.label}</>} />
.brand {
--select--option--radius: 1rem;
--select--option--hover--background: color-mix(in srgb, var(--color--primary) 15%, transparent);
--select--option--selected--background: color-mix(in srgb, var(--color--primary) 15%, transparent);
}
Tag
The tag slot changes what a tag says, and the remove button stays beside it, so it keeps its name. These tags look like the Tag, size for size, which you can use on its own wherever people remove or follow a label.
<Select className="brand" id="country" name="country" options={countries} multiple nativeOnTouch={false} tag={({ option }) => <>{option.value.toUpperCase()}</>} />
.brand {
--select--tag--radius: 1rem;
--select--tag--background: var(--color--primary);
--select--tag--color: var(--color--primary-contrast);
}
Arrow
The arrow slot swaps the chevron for an icon of your own, which still turns as the panel opens, and its tokens set its size and colour.
<Select className="brand" id="country" name="country" options={countries} nativeOnTouch={false} arrow={<ChevronsUpDownIcon />} />
.brand {
--select--arrow--color: var(--color--primary-text);
--select--arrow--size: 1.25rem;
}
Group label
The group-label slot gets each group's label and changes how it reads, and its tokens set its padding, size, weight and colour.
<Select className="brand" id="country" name="country" options={countries} nativeOnTouch={false} groupLabel={({ label }) => <>{label} shipping</>} />
.brand {
--select--group-label--color: var(--color--primary-text);
--select--group-label--font-weight: 700;
}
Separator
The separator slot draws the rule between options or groups, and its tokens set its colour and the space around it.
<Select className="brand" id="country" name="country" options={countries} nativeOnTouch={false} separator={<span class="rule" />} />
.brand {
--select--separator--color: var(--color--primary);
--select--separator--margin: 0.5rem;
}
UI Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
select/sizes | Should | Component | It takes the Input's five sizes, with the same heights, so it lines up with an Input and a Button | Open Components | Unit test, Visual regression test |
select/target-size | Must | Component | The field, each option and each remove button are at least 24 × 24px, and at least 44px tall on a touch screen | WCAG 2.5.8 (AA) | Emulation |
select/focus-visible | Must | Component | Focus shows a ring around the field, and the highlighted option an outline of its own | WCAG 2.4.7 (AA) | Keyboard |
select/contrast | Must | Both | Text has at least 4.5:1 and the field's border at least 3:1 against what's behind them | WCAG 1.4.3, 1.4.11 (AA) | Contrast checker |
select/not-colour-alone | Must | Component | A selected option has a check mark and a heavier weight, not only a colour | WCAG 1.4.1 (A) | Review |
select/tokens | Must | Component | Each part sets its look through --select--… token paths in the components layer | Open Components | Stylelint |
select/decorative-icons | Must | Component | Icons in the panel, the field and the tags are hidden from assistive technologies, so the label alone names each option | WCAG 1.1.1 (A) | Unit test, ARIA snapshot |
select/stable-size | Should | Component | Opening the panel, hovering and focusing don't change the field's size, and only tags can grow it | Open Components | Visual regression test |
Overview
How to build a select that picks one option or several from a list, looks like your brand, works with every form and hands phones their own picker.
User Experience
How a select behaves for every person, input and setting, from the ARIA pattern and the keyboard to forms, Dialogs and the native picker on touch screens.