Combobox

.combobox[data-ui="combobox"]

Combobox — a text field with a filtered list of suggestions.

Examples

First, the one you probably want

View “First, the one you probably want” as
<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.

And when that is not enough

View “And when that is not enough” as
  • Italy
  • Ireland
  • Iceland
<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.

Rows with more than a label

View “Rows with more than a label” as
  • Ada Lovelace ada@example.com · Engineering
  • Grace Hopper grace@example.com · Compilers
  • Katherine Johnson katherine@example.com · Flight dynamics
<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.

Many answers, not one

View “Many answers, not one” as
  • CSS
  • HTML
  • JavaScript
  • TypeScript
  • Accessibility
<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.

Level 2 tokens declared by combobox
TokenDefault
--combobox-chevron-iconurl("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-gapvar(--space-2)
--combobox-chip-remove-iconurl("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-size1rem
--combobox-empty-colorvar(--color-on-surface-muted)
--combobox-empty-font-sizevar(--font-size-sm)
--combobox-empty-padding-blockvar(--space-4)
--combobox-option-active-bgvar(--color-surface-sunken)
--combobox-option-active-colorvar(--color-on-surface)
--combobox-option-bgtransparent
--combobox-option-colorvar(--color-on-surface)
--combobox-option-description-colorvar(--color-on-surface-muted)
--combobox-option-description-font-sizevar(--font-size-xs)
--combobox-option-font-sizevar(--font-size-sm)
--combobox-option-gapvar(--space-2)
--combobox-option-min-sizevar(--target-size-compact)
--combobox-option-padding-blockvar(--space-2)
--combobox-option-padding-inlinevar(--space-3)
--combobox-option-radiusvar(--radius-field)
--combobox-panel-fallback-inline-size22rem
--combobox-panel-max-block-size18rem
--combobox-panel-offsetvar(--space-1)
--combobox-panel-paddingvar(--space-1)
--popover-gap0
--popover-offsetvar(--combobox-panel-offset, var(--space-1))
--popover-paddingvar(--combobox-panel-padding, var(--space-1))
--popover-max-inline-sizevar(--combobox-panel-fallback-inline-size, 22rem)
--popover-min-inline-sizevar(--combobox-panel-fallback-inline-size, 22rem)
--combobox-chevron-colorvar(--color-on-surface-muted)
--combobox-chevron-insetvar(--space-4)
--combobox-chevron-size0.375rem
--combobox-check-bgvar(--color-surface)
--combobox-check-border-colorvar(--color-border-strong)
--combobox-check-checked-bgvar(--color-primary)
--combobox-check-colorvar(--color-on-primary)
--combobox-check-size1rem
--combobox-chip-check-iconurl("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
  • :hover
  • data-combobox-active
  • data-combobox-empty
  • data-combobox-multiple
  • data-legacy
  • data-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

  1. Label the field. <label for> pointing at .combobox-input. A placeholder is not a label.
  2. Name the listboxaria-label on .combobox-list, or aria-labelledby pointing at the field's label.
  3. 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.
  4. 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 the name, and give each option a data-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.
  5. For remote suggestions, set data-loading on .combobox and aria-busy="true" on the input while the request is in flight, replace the <li>s when it returns, then call refresh() 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