Select

User Interface (UI)

How a select looks, from its parts and its sizes to one or several values, its states and the tokens that restyle each part.

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

  1. Trigger — The field itself, a <div> that draws the border, the background and the focus ring, and holds the parts below.
  2. Leading icon — Optional. An icon before the value, hidden from assistive technologies.
  3. Icon — Optional. An icon for each option, shown in the panel, beside the picked value and in each tag, hidden from assistive technologies.
  4. Tag — With multiple, one per value, with its own remove button. Tags sit beside the button, inside the field.
  5. Value — The picked option's label, or the placeholder, inside the <button> that has the combobox role.
  6. Arrow — The chevron, which turns as the panel opens, hidden from assistive technologies.
  7. Panel — The floating listbox, with its own scroll boundary, in the top layer while it's open.
  8. Group label — Optional. A heading for a group of options.
  9. Option — One choice, with its hover, highlighted, selected and disabled states and a check mark when it's picked.
  10. 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.

FranceSpain
France
Germany
Spain
New Zealand
Portugal
<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.

France
Spain
New Zealand
Australia
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.

Bug
Feature
Docs
Question
BugDocs
Bug
Feature
Docs
Question
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.

France
Germany
Spain
New Zealand
Portugal
France
Germany
Spain
New Zealand
Portugal
France
Germany
Spain
New Zealand
Portugal
France
Germany
Spain
New Zealand
Portugal
France
Germany
Spain
New Zealand
Portugal
<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 */}
SizeHeightFont sizePaddingTag height
xs24px12px8px18px
sm32px14px10px20px
md36px14px12px24px
lg40px16px14px28px
xl48px16px16px32px

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.

France
Germany
Spain
New Zealand
Portugal
France
Germany
Spain
New Zealand
Portugal
France
Germany
Spain
New Zealand
Portugal

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>
StateSelector on the active control or optionWhat it looks like
DefaultnoneA border at 3:1 against the page
Hover:hover on the field, where a pointer can hoverA stronger border
Focus:focus-visibleA 2px ring around the whole field, 2px away
Open[data-state="open"]The arrow turned, and the panel showing
Disabled:disabledFaded 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 optionA 2px outline inside the option, as focus stays on the control
Selected[aria-selected="true"], on an optionA heavier weight and a check mark
Disabled option[aria-disabled="true"], on an optionFaded, 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.

TokenDefaultPart
--select--height2.25rem, per sizeTrigger
--select--padding, --select--gap0.75rem, 0.5rem, per sizeTrigger
--select--font-size0.875rem, per sizeTrigger
--select--radius0.5rem, per sizeTrigger
--select--border-colorThe text colour at 55%Trigger
--select--backgroundtransparentTrigger
--select--icon--size, --select--leading--color1rem, the text colour at 70%Leading
--select--value--placeholder-colorThe text colour at 70%Value
--select--arrow--size, --select--arrow--color1rem, the text colour at 70%Arrow
--select--panel--offset, --select--panel--padding0.25remPanel
--select--panel--max-height16remPanel
--select--panel--radius0.5remPanel
--select--panel--border-colorThe text colour at 20%Panel
--select--panel--backgroundCanvasPanel
--select--panel--box-shadowThe Popover'sPanel
--select--option--padding-block, --select--option--padding-inline0.375rem, 0.5remOption
--select--option--radius0.25remOption
--select--option--colorinheritOption
--select--option--hover--backgroundThe text colour at 8%Option
--select--option--selected--background, --select--option--selected--font-weighttransparent, 600Option
--select--option--disabled--opacity0.5Option
--select--tag--height, --select--tag--padding1.5rem, 0.5rem, per sizeTag
--select--tag--radius, --select--tag--font-size0.25rem, 0.75remTag
--select--tag--background, --select--tag--colorThe text colour at 10%, inheritTag
--select--tag--icon--size1emIcon, in a tag
--select--group-label--padding-block, --select--group-label--padding-inline0.375rem, 0.5remGroup label
--select--group-label--font-size, --select--group-label--font-weight0.75rem, 500Group label
--select--group-label--colorThe text colour at 70%Group label
--select--separator--color, --select--separator--marginThe text colour at 20%, 0.25remSeparator

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:

StyleframeHereWhy
The triggerThe root .select and its controlTags hold buttons, which can't sit inside a <button>, so the field and the button are apart
The chiptagThe name we use for a removable label
labelgroup-labelThe Field owns "label", so a heading in the list needs a name of its own
invalidaria-invalidNo other component has an invalid prop: the Input, the Field and the rest read the attribute
Three sizesThe Input's fiveSo 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.

France
Germany
Spain
New Zealand
Portugal
<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.

France
Germany
Spain
New Zealand
Portugal
<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.

France (yours)
Germany
Spain
New Zealand
Portugal
<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.

FRES
France
Germany
Spain
New Zealand
Portugal
<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.

France
Germany
Spain
New Zealand
Portugal
<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.

France
Spain
New Zealand
Australia
<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.

France
Spain
New Zealand
Australia
<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

RuleLevelScopeRequirementBasisCheck
select/sizesShouldComponentIt takes the Input's five sizes, with the same heights, so it lines up with an Input and a ButtonOpen ComponentsUnit test, Visual regression test
select/target-sizeMustComponentThe field, each option and each remove button are at least 24 × 24px, and at least 44px tall on a touch screenWCAG 2.5.8 (AA)Emulation
select/focus-visibleMustComponentFocus shows a ring around the field, and the highlighted option an outline of its ownWCAG 2.4.7 (AA)Keyboard
select/contrastMustBothText has at least 4.5:1 and the field's border at least 3:1 against what's behind themWCAG 1.4.3, 1.4.11 (AA)Contrast checker
select/not-colour-aloneMustComponentA selected option has a check mark and a heavier weight, not only a colourWCAG 1.4.1 (A)Review
select/tokensMustComponentEach part sets its look through --select--… token paths in the components layerOpen ComponentsStylelint
select/decorative-iconsMustComponentIcons in the panel, the field and the tags are hidden from assistive technologies, so the label alone names each optionWCAG 1.1.1 (A)Unit test, ARIA snapshot
select/stable-sizeShouldComponentOpening the panel, hovering and focusing don't change the field's size, and only tags can grow itOpen ComponentsVisual regression test
Copyright © 2026