User Interface (UI)
Let's start with how a combobox looks: its parts, one or several values, groups, icons, sizes, its states and the variables that restyle each part.
Anatomy
- Field — The root, a
<div>that draws the border, the background and the focus ring, and holds the parts below. - Leading icon — Optional. An icon before the text, hidden from assistive technologies.
- Icon — Optional. An icon for each option, shown in the panel, beside the picked option's label and in each tag, hidden from assistive technologies.
- Text — The
<input>people type in, which has thecomboboxrole and shows the picked option's label. - Toggle — A
<button>with the chevron, which opens and closes the panel and turns as it does. It's out of the tab order. - Tag — With
multiple, one per value, with its own remove button, on a row under the text. - 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 suggestion, with its hover, highlighted, selected and disabled states, and a check mark when it's picked.
- Separator — Optional. A rule between options or groups.
- Create option — With
creatable, a last option that adds what people typed. In its place, the panel says "No results" when nothing matches.
The field is the root, as on the Select, and the <input> fills its first row, under the icons, so it lines up with an Input of the same size. The panel, its options, its groups, its separators and the tags are the Select's, drawn by the same code with the same tokens, so the two components look the same wherever they meet. You can see the order of the parts in the DOM contract.
One value or several
A Combobox picks one value by default, and shows its label in the text field. With multiple, it picks any number, and each one becomes a tag on a row under the text, which grows onto more lines as tags wrap. The text clears after each pick, so people can search for the next one, and the panel stays open while they do.
<Field label="Countries">
<Combobox id="countries" name="countries" multiple options={countries} placeholder="Type a country" />
</Field>
We put the tags under the text, rather than before it on the same row, since people need to see what they type as they type it, however many values they've picked. The tags come after the text in the page too, so Tab reaches the remove buttons in the order people read them, the way the Select's do.
Groups and separators
Options can sit in groups, each with a heading, and a separator can go between groups or options, the same list the Select takes. As people type, a group with no match hides along with its heading, and so does a separator with nothing on one side of it, so the panel never shows an empty heading or two rules in a row.
<Field label="Destination">
<Combobox id="destination" name="destination" options={destinations} placeholder="Type a country" />
</Field>
Icons
An option can have an icon, like a label's symbol or a country's flag, which helps people scan the suggestions and recognise what they picked. The icon slot gets each option, and the Combobox shows what it returns before the option's label in the panel, before the text while it shows the picked option's label and in each tag, the way the Select does, so a picked value looks the same in both.
const icons = { bug: BugIcon, feature: SparklesIcon, docs: BookOpenIcon, question: CircleHelpIcon };
<Field label="Labels">
<Combobox id="labels" name="labels" multiple options={labels} placeholder="Type a label"
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. Once people type, the text no longer names the picked option, so its icon leaves the field until the text shows its label again, and text people kept with allowCustomValue has no option, so it has no icon. 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.
Sizes
There are five sizes, xs, sm, md, lg and xl, the same as the Input and the Select, so a combobox lines up with them and with a Button in a row. The size also sets the panel's text and the tags' height, so they match the field they come from.
<Field label="Country" size="xs">
<Combobox id="country" name="country" multiple options={countries} value={["fr"]} />
</Field>
Keep in mind that on touch screens the text is never smaller than 16px, whatever the size, or Safari on iOS zooms the page in when the text field gets focus, as with the Input.
States
The field has the Input's states, and the options have the Select's:
- Hover, on pointers that can hover, darkens the border, and an option under the pointer gets a background.
- Focus draws a ring around the whole field while the text has focus, and the highlighted option, the one the arrow keys moved to, gets a 2px outline of the same colour, since focus stays in the text.
- Open turns the chevron.
- Disabled dims the field to 50%, and leaves it out of the form.
- Read-only draws a dashed border, keeps the value and its contrast, and doesn't open.
- Invalid draws a second pixel of border in the error colour, while the Field shows the message.
- A selected option is bolder and has a check mark, so it never rests on one cue, and a disabled option is at 50%.
Pick a country we ship to.
<Field label="Disabled" disabled>
<Combobox id="disabled" name="disabled" options={countries} value="fr" />
</Field>
<Field label="Read-only">
<Combobox id="readonly" name="readonly" options={countries} value="fr" readonly />
</Field>
<Field label="Invalid" error="Pick a country we ship to.">
<Combobox id="invalid" name="invalid" options={countries} placeholder="Type a country" />
</Field>
The focus ring and the highlight take the theme's --color--focus, the same as every other component's focus, so they change with your theme rather than with a variable of the Combobox's. Each state reads the attribute that exposes it, like aria-invalid or aria-selected, so what people see and what assistive technologies report can't drift apart.
Tokens
The Combobox reads the Select's variables for every part the two draw alike: the field, its sizes, the panel, the options, the group labels, the separators and the tags. So a class that sets --select--option--radius rounds the options of both, and there's one place to change how a listbox looks across your product. That's what the Design Tokens page's tokens/component-scope allows for parts a component shares, and the contract lists them under shared.
| Part | Shared tokens, from the Select |
|---|---|
| Field | --select--height, --select--padding, --select--gap, --select--font-size, --select--radius, --select--border-color, --select--background, --select--icon--size, --select--leading--color, --select--value--placeholder-color |
| Arrow | --select--arrow--size, --select--arrow--color |
| Panel | --select--panel--offset, --select--panel--padding, --select--panel--max-height, --select--panel--radius, --select--panel--border-color, --select--panel--background, --select--panel--box-shadow |
| Option | --select--option--padding-block, --select--option--padding-inline, --select--option--radius, --select--option--color, --select--option--hover--background, --select--option--selected--background, --select--option--selected--font-weight, --select--option--disabled--opacity |
| Tag | --select--tag--height, --select--tag--padding, --select--tag--radius, --select--tag--font-size, --select--tag--background, --select--tag--color, --select--tag--icon--size |
| Group label | --select--group-label--padding-block, --select--group-label--padding-inline, --select--group-label--font-size, --select--group-label--font-weight, --select--group-label--color |
| Separator | --select--separator--color, --select--separator--margin |
The Combobox has variables of its own only for what the Select doesn't draw, the "No results" text:
| Token | What it styles |
|---|---|
--combobox--empty--padding-block | The space above and below "No results" |
--combobox--empty--padding-inline | The space before and after it, the options' own |
--combobox--empty--color | Its colour, the text colour at 70%, which has 4.5:1 like a placeholder |
Here's one class, on a Combobox and on a Select, that restyles the field, the panel, the options and the tags of both:
<Combobox id="countries" name="countries" className="brand-field" multiple options={countries} />
<Select id="country" name="country" className="brand-field" options={countries} />
/* brand.css */
.brand-field {
--select--radius: 1.25rem;
--select--border-color: var(--color--primary);
--select--panel--radius: 1rem;
--select--panel--border-color: var(--color--primary);
--select--option--radius: 1rem;
--select--option--selected--background: color-mix(in srgb, var(--color--primary) 15%, transparent);
--select--tag--radius: 1rem;
--select--tag--background: var(--color--primary);
--select--tag--color: var(--color--primary-contrast);
}
Set the variables from a class of yours, never with an inline style, since the components' styles sit in the components cascade layer, and a class outside it wins without a fight. The Design Tokens page explains why.
The sections below restyle each part on its own, with its variables and, where it has one, its slot. The leading icon restyles with the field, and the option icons have their own section.
Field
The root draws the field, so its tokens set the border, the background and the radius, and the leading slot adds an icon before the text. There's no value slot, as the Select has, since the text field shows the picked option's label and people edit it as they type.
<Combobox className="brand" id="country" name="country" options={countries} leading={<GlobeIcon />} />
.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.
<Combobox className="brand" id="country" name="country" options={countries} />
.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.
<Combobox className="brand" id="country" name="country" options={countries} option={({ option, selected }) => <>{option.label}{selected && " (yours)"}</>} />
.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 are the Select's tags, so they look like the Tag, size for size.
<Combobox className="brand" id="country" name="country" multiple options={countries} tag={({ option }) => <>{option.value.toUpperCase()}</>} />
.brand {
--select--tag--radius: 1rem;
--select--tag--background: var(--color--primary);
--select--tag--color: var(--color--primary-contrast);
}
Toggle
The arrow slot swaps the toggle's chevron for an icon of your own, which still turns as the panel opens. Its tokens, the Select's arrow tokens, set its size and colour, and the button around it keeps its name and its press area.
<Combobox className="brand" id="country" name="country" options={countries} 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.
<Combobox className="brand" id="country" name="country" options={destinations} 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.
<Combobox className="brand" id="country" name="country" options={destinations} separator={<span className="rule" />} />
.brand {
--select--separator--color: var(--color--primary);
--select--separator--margin: 0.5rem;
}
No results
The "No results" text is the one part the Select doesn't draw, so its tokens are the Combobox's own, and the emptyLabel prop changes what it says. Type something no country matches, like "xyz", to see it.
<Combobox className="brand" id="country" name="country" options={countries} emptyLabel="No countries match" />
.brand {
--combobox--empty--padding-block: 0.75rem;
--combobox--empty--color: var(--color--primary-text);
}
UI Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
combobox/visible-focus | Must | Component | The field shows a focus ring while its text has focus, and the highlighted option an outline at least 2px thick | WCAG 2.4.7 (AA) | Keyboard, Visual regression test |
combobox/selected-not-colour | Must | Component | A picked option shows a check mark as well as its weight, and its tag, with multiple | WCAG 1.4.1 (A) | Visual regression test |
combobox/contrast | Must | Both | The text, the placeholder and "No results" have 4.5:1, and the border, the chevron and the focus ring 3:1, in light and dark mode | WCAG 1.4.3, 1.4.11 (AA) | Contrast checker |
combobox/target-size | Must | Component | The toggle and each remove button are at least 24 × 24px to press, and 44px tall where a pointer is coarse, like the options | Open Components, beyond WCAG 2.5.8 (AA) | Emulation, Review |
combobox/shared-tokens | Should | Component | The field, the panel, the options, the group labels, the separators and the tags read the Select's variables, so one class restyles both | Open Components | Review |
combobox/sizes | Should | Component | Its five sizes are the Input's and the Select's, and the panel's text and the tags follow the field's size | Open Components | Visual regression test |
combobox/text-size-touch | Must | Component | Its text is at least 16px where any pointer is coarse, so Safari doesn't zoom in on focus | Open Components | Emulation |
combobox/no-layout-shift | Must | Component | Opening the panel, filtering and picking move nothing else on the page | Open Components | Visual regression test |
combobox/reflow | Must | Component | The field and the panel fit a 320px-wide screen without scrolling sideways, and long labels wrap | WCAG 1.4.10 (AA) | Emulation |
combobox/reduced-motion | Should | Component | The chevron and the panel don't animate when people ask for less motion | WCAG 2.3.3 (AAA) | Emulation |
combobox/forced-colors | Should | Component | Focus, the highlight, the panel's edge and disabled options stay visible in forced colours | Open Components | Emulation |
FAQ
shared.Tab goes from the text to the tags, in the order people read them. If you'd like them on the same row, you can lay them out that way from the shared styles, as long as the order in the page follows what people see.Overview
How to build a combobox that filters a list of suggestions as people type, picks one or several, keeps text you allow and works with every form, keyboard and phone.
User Experience
How a combobox behaves for every person, input and setting, from the ARIA pattern and the keyboard to filtering, typed values, forms and the on-screen keyboard.