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.
<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"].
<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.
| State | Look |
|---|---|
.is-active | Hover background — the keyboard cursor |
[aria-selected="true"] | Brand tint, heavier weight, tick visible |
| Both | A 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 withdata-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.
| 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
| 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.jsgives the inputrole="combobox",aria-autocomplete="list"and anaria-expandedit keeps in step, and each optionrole="option"witharia-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-activerather than focus, which is what the combobox pattern requires — the reader keeps typing while the highlight moves. The link between them isaria-activedescendant. -
The control needs a label like any other. A
<label for>pointing at.combo-input, oraria-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-liveregion. Deck does not do this for you, and it is the most commonly missed part of the pattern. -
.combo-emptyshould 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
.segmentedor 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
.searchfield and a results page. -
Not for free text with suggestions. That is
<input list>with a<datalist>, which is native and needs nothing. Reach fordata-createonly 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.