Combobox

.combo is a filtering select: type to narrow a long list, pick one or several, with tokens for multiple selection and grouped options. The design decision that matters is underneath — it wraps a real <select> that stays in the DOM and stays in sync, so a form post works with no server-side handling of its own.

When to use it

Use it when a select would be unusable: more than about twenty options, options the reader knows the name of but not the position of, or multiple selection. Below that, a native select is better — it needs no JavaScript and gets the platform's own picker on a phone.

<div class="combo" data-deck-combo data-multi data-create
     data-placeholder="Search repositories">
  <select hidden multiple name="repos">
    <option value="deck">deck</option>
    <option value="keel">keel</option>
    <option value="helm">helm</option>
  </select>
</div>

data-multi turns on tokens (or take it from the select's own multiple), data-create allows values that are not in the list, and data-placeholder sets the input's placeholder.

The hidden select is the point

Most combobox components replace the native control entirely and then need a hidden input, a serialiser and something on the server to read it. Deck keeps the <select> — hidden, but present, with its name and its options — and mirrors every change back into it.

Three consequences follow, and they are the reason to prefer this shape:

  • An ordinary form post works. No JSON, no hidden field, no special case in the handler. The server sees a select.
  • Server-side rendering is trivial. Render the <select> with the right options selected and the combobox comes up correct — the initial state is in the markup rather than in a script.
  • It degrades. With JavaScript off the reader gets a plain select. Not the filtering, but a working control with the real options in it.

That last one only holds if you leave the hidden attribute off and let Deck hide the select itself. As written above — <select hidden> — a reader without JavaScript gets nothing. Which is right depends on whether you support that case; the source comment shows hidden, so the default is the enhanced-only version.

The control

.combo-control is the box, styled to match .input — same border, radius, focus ring — so a combobox in a form does not look like a different species. .combo-input is the text field inside it, and .combo-toggle the chevron, which rotates 180 degrees on .is-open.

Focus is handled twice: .combo:has(.combo-input:focus) .combo-control and .combo-control:focus-within. The first covers the input, the second anything else focusable inside — a token's remove button, the clear control.

Static markup, so the tokens hold still. Normally deck.js builds all of this.
deck keel
<div class="field" style="max-inline-size:26rem"> <label class="label" for="dx-combo">Repositories</label> <div class="combo"> <div class="combo-control"> <span class="combo-token"><span>deck</span><button aria-label="Remove deck">×</button></span> <span class="combo-token"><span>keel</span><button aria-label="Remove keel">×</button></span> <input class="combo-input" id="dx-combo" role="combobox" aria-expanded="false" placeholder="Search repositories"> <button class="combo-toggle" aria-label="Show options"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-down"></use></svg> </button> </div> </div> </div>
<div class="field" style="max-inline-size:26rem">
  <label class="label" for="dx-combo">Repositories</label>
  <div class="combo">
    <div class="combo-control">
      <span class="combo-token"><span>deck</span><button aria-label="Remove deck">×</button></span>
      <span class="combo-token"><span>keel</span><button aria-label="Remove keel">×</button></span>
      <input class="combo-input" id="dx-combo" role="combobox" aria-expanded="false" placeholder="Search repositories">
      <button class="combo-toggle" aria-label="Show options">
        <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-down"></use></svg>
      </button>
    </div>
  </div>
</div>

.combo-tokens is display: contents, so the tokens are laid out by the control's own flex row rather than forming a nested box. That is what lets a token and the input share a line and wrap together.

Option rows

.combo-option has the same three-part shape as a .list-row: .combo-option-main takes the space and is allowed to shrink, .combo-option-sub is a second muted line, .combo-option-trail sits at the end and never shrinks. .combo-check is the tick, pushed to the end and revealed by [aria-selected="true"].

The list shown inline rather than floating, so it can be inspected
Recent
<div class="combo is-open" style="max-inline-size:26rem"> <div class="combo-list" style="position:static;display:block"> <div class="combo-group">Recent</div> <button class="combo-option is-active" role="option" aria-selected="true"> <span class="combo-option-main"> <span><mark>de</mark>ck</span> <span class="combo-option-sub">One stylesheet, no build step</span> </span> <span class="combo-option-trail">CSS</span> <svg class="icon combo-check" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#check"></use></svg> </button> <button class="combo-option" role="option" aria-selected="false"> <span class="combo-option-main"><span><mark>de</mark>ck-icons</span></span> <span class="combo-option-trail">SVG</span> </button> <div class="combo-sep"></div> <button class="combo-option" role="option" aria-disabled="true"> <span class="combo-option-main"><span>archived-repo</span></span> </button> </div> </div>
<div class="combo is-open" style="max-inline-size:26rem">
  <div class="combo-list" style="position:static;display:block">
    <div class="combo-group">Recent</div>
    <button class="combo-option is-active" role="option" aria-selected="true">
      <span class="combo-option-main">
        <span><mark>de</mark>ck</span>
        <span class="combo-option-sub">One stylesheet, no build step</span>
      </span>
      <span class="combo-option-trail">CSS</span>
      <svg class="icon combo-check" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#check"></use></svg>
    </button>
    <button class="combo-option" role="option" aria-selected="false">
      <span class="combo-option-main"><span><mark>de</mark>ck-icons</span></span>
      <span class="combo-option-trail">SVG</span>
    </button>
    <div class="combo-sep"></div>
    <button class="combo-option" role="option" aria-disabled="true">
      <span class="combo-option-main"><span>archived-repo</span></span>
    </button>
  </div>
</div>

The matched substring is wrapped in <mark> by deck.js, and Deck restyles it: no yellow background, just a heavier weight and a tinted underline. A highlight that inverts the colours of half a word is harder to read than the word was.

Two highlight states, deliberately different

An option can be active — where the keyboard is — and selected — chosen. They are different things and they need different looks, or a reader arrowing through a list cannot tell where they are.

Option states
StateLook
.is-activeHover background — the keyboard cursor
[aria-selected="true"]Brand tint, heavier weight, tick visible
BothA blend of the two, so the cursor is visible on a selected row
[aria-disabled="true"]Faded, pointer-events: none

.is-active is used for both mouse hover and keyboard position, which keeps the two from fighting: moving the mouse over the list moves the same highlight the arrow keys move.

Escaping the modal

By default .combo-list is a positioned sibling, and .is-above flips it when there is no room below. That works until the combobox is inside something with overflow: hidden — a modal, a card, a scroll container — at which point the list is clipped. It is the bug that bites every combobox eventually.

src/25-anchor.css fixes it where the browser has anchor positioning: the list goes in the top layer, sized to the control with anchor-size(inline) and flipped with position-try-fallbacks: flip-block. No clipping ancestor can reach it.

Where anchor positioning is missing, the sibling positioning and .is-above remain — so the component works everywhere and works better where the platform allows.

Create, count, loading, empty

  • .combo-create — the "Add …" row for a value not in the list, shown with data-create.
  • .combo-count — a "+3 more" pill when tokens overflow the control.
  • .combo-loading — a placeholder row while options are fetched.
  • .combo-empty — "No matches", which should say what was searched for.
  • .combo-hint — a line under the control, for a format note or a shortcut.
  • .combo-clear — removes every selection at once.

Classes

Generated from src/11-combobox.css by tools/docs/extract.mjs.

Classes, generated from the stylesheet source
Class Layer What it does Source
.combo deck.components A filtering select. It wraps a real <select> that stays in the DOM and stays in sync, so an ordinary form post works with no server-side handling, server-side rendering is just marking options selected, and without JavaScript the reader still gets a working control. 11-combobox.css:12
.combo-check deck.components No declarations. 11-combobox.css:173
.combo-clear deck.components Sets color. 11-combobox.css:74
.combo-control deck.components The box, styled to match .input so a combobox in a form does not look like a different species. Focus is handled twice — :has(.combo-input:focus) and :focus-within — so a token's remove button lights the box too. 11-combobox.css:18
.combo-count deck.components Sets font-size, font-weight, color, flex. 11-combobox.css:109
.combo-create deck.components Sticky "create new" row for tag-style inputs 11-combobox.css:213
.combo-empty deck.components The no-matches row. Name what was searched for — "No repositories match 'deckk'" tells a reader who mistyped what they actually typed. 11-combobox.css:201
.combo-group deck.components A sticky heading above a group of options. 11-combobox.css:188
.combo-hint deck.components Sets display, gap, padding, border-block-start, and 2 more. 11-combobox.css:223
.combo-input deck.components The text field. deck.js gives it role="combobox" and keeps aria-expanded in step; arrow keys move .is-active rather than focus, so the reader can keep typing while the highlight moves. 11-combobox.css:48
.combo-list deck.components The option list. A positioned sibling by default, which a clipping ancestor can cut off; 25-anchor.css moves it to the top layer where anchor positioning exists, which is the fix for the combobox-inside-a-modal bug. 11-combobox.css:121
.combo-loading deck.components The no-matches row. Name what was searched for — "No repositories match 'deckk'" tells a reader who mistyped what they actually typed. 11-combobox.css:201
.combo-option deck.components The matched substring, wrapped by deck.js 11-combobox.css:143
.combo-option-main deck.components No declarations. 11-combobox.css:159
.combo-option-sub deck.components No declarations. 11-combobox.css:160
.combo-option-trail deck.components No declarations. 11-combobox.css:161
.combo-sep deck.components Sets block-size, background, margin. 11-combobox.css:197
.combo-toggle deck.components Sets inline-size, block-size, display, place-items, and 4 more. 11-combobox.css:61
.combo-token deck.components Sets display, align-items, gap, min-block-size, and 9 more. 11-combobox.css:78
.combo-tokens deck.components Sets display. 11-combobox.css:77
.is-above deck.components No declarations. 11-combobox.css:141
.is-active deck.components No declarations. 07-components.css:132
.is-disabled deck.components No declarations. 05-buttons.css:63
.is-invalid deck.components No declarations. 06-forms.css:110
.is-open deck.components No declarations. 11-combobox.css:73

Tokens

Design tokens this component reads
Token Live Declared value
--surface light-dark(var(--ink-0), var(--ink-950))
--surface-hover light-dark(var(--ink-50), var(--ink-900))
--line light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral)))
--line-strong light-dark(var(--ink-300), oklch(36% .018 var(--hue-neutral)))
--ink-400 oklch(70.8% calc(var(--chroma-neutral) * 2.6) var(--hue-neutral))
--focus light-dark(var(--brand-500), var(--brand-300))
--ring 0 0 0 3px light-dark(oklch(from var(--focus) l c h / .32), oklch(from var(--focus) l c h / .42))
--brand light-dark(var(--brand-600), var(--brand-400))
--brand-soft light-dark(var(--brand-50), oklch(26% .05 var(--hue-brand)))
--brand-soft-text light-dark(var(--brand-800), var(--brand-200))
--brand-300 oklch(80.4% calc(var(--chroma-brand) * .70) var(--hue-brand))
--bad-500 oklch(58% calc(var(--chroma-status) * 1.25) var(--hue-bad))
--text-faint light-dark(var(--ink-500), var(--ink-500))
--text-muted light-dark(var(--ink-600), var(--ink-400))
--text-xs .75rem
--text-2xs .6875rem
--r-sm 7px
--space-1 .25rem
--space-2 .5rem
--space-3 .75rem
--dur-2 190ms

Accessibility

  • The ARIA is built by the script, not by you. deck.js gives the input role="combobox", aria-autocomplete="list" and an aria-expanded it keeps in step, and each option role="option" with aria-selected. That is the right division: the roles promise behaviour, and the thing that implements the behaviour should own them.
  • Focus stays in the input. Arrow keys move .is-active rather than focus, which is what the combobox pattern requires — the reader keeps typing while the highlight moves. The link between them is aria-activedescendant.
  • The control needs a label like any other. A <label for> pointing at .combo-input, or aria-labelledby. The placeholder is not one.
  • Token remove buttons need names. "×" is announced as "times" or skipped. aria-label="Remove deck", naming the token — as the example above does.
  • Announce the result count. Filtering a list of two hundred down to three is silent unless you put "3 results" in an aria-live region. Deck does not do this for you, and it is the most commonly missed part of the pattern.
  • .combo-empty should name the search. "No matches" is less useful than "No repositories match 'deckk'" — and it tells a reader who mistyped what they actually typed.
  • The match highlight is not announced, and does not need to be — <mark> is presentational here. It is styled without a background change specifically so it does not reduce contrast.
  • Selected and active must look different. They do, and the combined state has a third look. If you restyle these, keep all three distinguishable, or keyboard users lose their place.
  • Options are 40px-ish rows. Comfortable enough on touch, and the list scrolls rather than the page — but a long list on a phone is still a lot of scrolling inside a small box. Consider a sheet for the mobile case.

Right to left

The tick is pushed with margin-inline-start: auto, groups and options use padding-inline, and the anchored list uses logical position-area keywords. Everything mirrors without a rule of its own.

The chevron's rotate: 180deg on open is symmetrical about the vertical axis, so it does not need mirroring either — unlike the select's gradient chevron, which does.

Reduced motion

The list animates in with dk-combo-in, tokens animate as they are added, and the chevron rotates. All are collapsed to .01ms under prefers-reduced-motion: reduce.

src/16-motion.css also shakes an invalid combobox once — the selector is .field.is-invalid:not(.was-shaken) :is(.input, .textarea, .select, .combo-control), so a combobox and an input misbehave identically. It is declared inside @media (prefers-reduced-motion: no-preference), so it is never declared rather than declared and cancelled.

Printing

.combo-list is in the never-print list, and .combo-control is in the shared form-control rule — a plain #999 border on white, with min-block-size: auto. So a printed combobox shows the chosen tokens in a box and no dropdown, which is the right reading of a filled-in form.

Overriding it

@layer app.components {
  /* A taller list */
  .combo-list { max-block-size: 24rem; }

  /* Bring back a conventional match highlight */
  .combo-option mark {
    background: var(--warn-100);
    text-decoration: none;
  }

  /* Tokens that wrap onto their own line rather than sharing with the input */
  .combo-tokens { display: flex; flex-wrap: wrap; inline-size: 100%; }
}

That last one changes display: contents to a real box, which is the one override here with a structural effect — the tokens stop participating in the control's flex row and become a block above the input.

When not to use it

  • Not for a short list. Under about twenty options a native select is better: no script, platform picker on a phone, and nothing to get wrong.
  • Not for one of three. Use .segmented or radios — showing all the options costs nothing and saves a click.
  • Not as a search box. A combobox picks from a known set. Searching a corpus is a .search field and a results page.
  • Not for free text with suggestions. That is <input list> with a <datalist>, which is native and needs nothing. Reach for data-create only when the new value has to join the real option set.
  • Not without announcing the filtered count. The most important part of the interaction — how many results are left — is the part Deck does not provide, and a combobox without it is silent for a screen-reader user.