Combobox
Combobox — a text field with a filtered list of suggestions.
Examples
<div class="field">
<label class="field-label" for="country-native">Country</label>
<input class="input" id="country-native" name="country" list="countries" autocomplete="off" />
<datalist id="countries">
<option value="Italy"></option>
<option value="Ireland"></option>
<option value="Iceland"></option>
</datalist>
</div>No class from this library, no data-ui, no ARIA — that is the point.
The browser filters, opens, closes, runs the keyboard model and exposes the input as a combobox with nothing written by hand. Its one limitation is total: the popup is a browser-process widget, so it cannot be styled and cannot say "no results". If you can live with that, stop here. The .field wrapper is the only addition, and it is here so the gap between this label and its input is the same one every other field on the site has — without it the two examples below sat at different distances and read as two different components.
- Italy
- Ireland
- Iceland
No matching countries
<div class="field">
<label class="field-label" for="country">Country</label>
<div class="combobox" data-ui="combobox">
<input class="input combobox-input" id="country" type="text" />
<input class="combobox-value" type="hidden" name="country" />
<div class="combobox-panel popover" popover>
<ul class="combobox-list" aria-label="Countries">
<li class="combobox-option" data-combobox-value="IT">Italy</li>
<li class="combobox-option" data-combobox-value="IE">Ireland</li>
<li class="combobox-option" data-combobox-value="IS">Iceland</li>
</ul>
<p class="combobox-empty" hidden>No matching countries</p>
</div>
</div>
</div>Four cases justify it and you need one: remote suggestions, a visible "no results" state, rows that are more than one line, or a list that must match the rest of the design system.
Not on that list: "the native popup looks different on Windows". There is no ARIA anywhere in this markup, and that is the guard rather than an omission — combobox.ts writes role="combobox", aria-expanded, aria-controls, aria-activedescendant and the listbox and option roles itself, so markup copied without the script cannot claim a keyboard contract that is not there.
- Ada Lovelace ada@example.com · Engineering
- Grace Hopper grace@example.com · Compilers
- Katherine Johnson katherine@example.com · Flight dynamics
Nobody by that name
<div class="field">
<label class="field-label" for="assignee">Assignee</label>
<div class="combobox" data-ui="combobox">
<input class="input combobox-input" id="assignee" type="text" placeholder="Start typing a name…" />
<input class="combobox-value" type="hidden" name="assignee" />
<div class="combobox-panel popover" popover>
<ul class="combobox-list" aria-label="People">
<li class="combobox-option" data-combobox-value="u_18" data-combobox-label="Ada Lovelace">
<span class="avatar avatar-sm" aria-hidden="true">AL</span>
<span class="combobox-option-text">
<span class="combobox-option-label">Ada Lovelace</span>
<span class="combobox-option-description">ada@example.com · Engineering</span>
</span>
</li>
<li class="combobox-option" data-combobox-value="u_22" data-combobox-label="Grace Hopper">
<span class="avatar avatar-sm" aria-hidden="true">GH</span>
<span class="combobox-option-text">
<span class="combobox-option-label">Grace Hopper</span>
<span class="combobox-option-description">grace@example.com · Compilers</span>
</span>
</li>
<li class="combobox-option" data-combobox-value="u_31" data-combobox-label="Katherine Johnson">
<span class="avatar avatar-sm" aria-hidden="true">KJ</span>
<span class="combobox-option-text">
<span class="combobox-option-label">Katherine Johnson</span>
<span class="combobox-option-description">katherine@example.com · Flight dynamics</span>
</span>
</li>
</ul>
<p class="combobox-empty" hidden>Nobody by that name</p>
</div>
</div>
</div>This is the case <datalist> cannot serve at all: a row with a face, a name and a second line under it.
.combobox-option only means anything inside a .combobox: on its own it is a row with no tokens behind it and nothing listening for a click. data-combobox-label is what the module puts in the field when the row is chosen: without it the input would receive the row’s entire text content, address and department included.
- CSS
- HTML
- JavaScript
- TypeScript
- Accessibility
No matching skills
<div class="field">
<label class="field-label" for="skills">Skills</label>
<div class="combobox" data-ui="combobox" data-combobox-multiple>
<input class="input combobox-input" id="skills" type="text"
placeholder="Add a skill…" />
<input class="combobox-value" type="hidden" name="skills" />
<div class="combobox-panel popover" popover>
<ul class="combobox-list" aria-label="Skills">
<li class="combobox-option" data-combobox-value="css">CSS</li>
<li class="combobox-option" data-combobox-value="html">HTML</li>
<li class="combobox-option" data-combobox-value="js" aria-selected="true">JavaScript</li>
<li class="combobox-option" data-combobox-value="ts">TypeScript</li>
<li class="combobox-option" data-combobox-value="a11y" aria-selected="true">Accessibility</li>
</ul>
<p class="combobox-empty" hidden>No matching skills</p>
</div>
</div>
</div>One attribute — data-combobox-multiple — and the field picks a set.
Choosing adds a chip and clears what you typed so the next one is a keystroke away; choosing the same option again removes it, and so does Backspace in an empty field. The list never reorders itself, because a list that moves under the pointer is how you click the wrong row. What submits is one hidden input per chip, all under the same name, which is how HTML has always sent a repeated field — no separator to escape and nothing for the server to guess. The two attributes that carry meaning are split: aria-selected is what is chosen, and the row the arrow keys are on is data-combobox-active, because in this mode they are no longer the same row.
Tokens 37
Level 2, declared on .combobox itself. Set any of them on that selector to restyle this component without touching the skin.
| Token | Default |
|---|---|
| --combobox-chevron-icon | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") |
| --combobox-chip-gap | var(--space-2) |
| --combobox-chip-remove-icon | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18M6 6l12 12'/%3E%3C/svg%3E") |
| --combobox-chip-remove-size | 1rem |
| --combobox-empty-color | var(--color-on-surface-muted) |
| --combobox-empty-font-size | var(--font-size-sm) |
| --combobox-empty-padding-block | var(--space-4) |
| --combobox-option-active-bg | var(--color-surface-sunken) |
| --combobox-option-active-color | var(--color-on-surface) |
| --combobox-option-bg | transparent |
| --combobox-option-color | var(--color-on-surface) |
| --combobox-option-description-color | var(--color-on-surface-muted) |
| --combobox-option-description-font-size | var(--font-size-xs) |
| --combobox-option-font-size | var(--font-size-sm) |
| --combobox-option-gap | var(--space-2) |
| --combobox-option-min-size | var(--target-size-compact) |
| --combobox-option-padding-block | var(--space-2) |
| --combobox-option-padding-inline | var(--space-3) |
| --combobox-option-radius | var(--radius-field) |
| --combobox-panel-fallback-inline-size | 22rem |
| --combobox-panel-max-block-size | 18rem |
| --combobox-panel-offset | var(--space-1) |
| --combobox-panel-padding | var(--space-1) |
| --popover-gap | 0 |
| --popover-offset | var(--combobox-panel-offset, var(--space-1)) |
| --popover-padding | var(--combobox-panel-padding, var(--space-1)) |
| --popover-max-inline-size | var(--combobox-panel-fallback-inline-size, 22rem) |
| --popover-min-inline-size | var(--combobox-panel-fallback-inline-size, 22rem) |
| --combobox-chevron-color | var(--color-on-surface-muted) |
| --combobox-chevron-inset | var(--space-4) |
| --combobox-chevron-size | 0.375rem |
| --combobox-check-bg | var(--color-surface) |
| --combobox-check-border-color | var(--color-border-strong) |
| --combobox-check-checked-bg | var(--color-primary) |
| --combobox-check-color | var(--color-on-primary) |
| --combobox-check-size | 1rem |
| --combobox-chip-check-icon | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E") |
Variants and states
Variants
.input-lg.input-sm
Inside it
.combobox-option.combobox-chip-remove.combobox-chips.combobox-list.combobox-panel.avatar.combobox-chip.combobox-empty.combobox-input.combobox-option-description.combobox-option-label.combobox-option-text.icon
State it reads
Read from the platform, never mirrored into a class that could disagree with it.
:focus:hoverdata-combobox-activedata-combobox-emptydata-combobox-multipledata-legacydata-loading
Before you ship it
This one needs a script. Opt the markup in with data-ui="combobox" and import import 'mostlycss/js/components/combobox' — that specifier and not the package root, which is a bundle carrying a second copy of the registry, so a page holding both initialises every element twice. What you lose without the script is listed below — for some components that is only polish, for others it is the whole interaction.
What you have to do 5 requirements
- Label the field.
<label for>pointing at.combobox-input. A placeholder is not a label. - Name the listbox —
aria-labelon.combobox-list, oraria-labelledbypointing at the field's label. - Keep the panel inside
.combobox. The level 2 tokens are declared on the root and inherit into the panel through the DOM, which the top layer does not change. Outside it, the option rows fall back to their level 1 defaults — legible, but not yours. - Decide what submits. The visible input holds text. If you need a code rather than a label, add
<input class="combobox-value" type="hidden">with thename, and give each option adata-combobox-value. The module keeps it in step with what was chosen and clears it whenever the typed text stops matching an option exactly, so a stale code can never be submitted beside text that contradicts it. Validate on the server that it is non-empty if the choice is mandatory. - For remote suggestions, set
data-loadingon.comboboxandaria-busy="true"on the input while the request is in flight, replace the<li>s when it returns, then callrefresh()from the package entry point so the new options are wired. The spinner is visual only.
src/css/components/combobox.css · npx mostlycss add combobox