Combobox
Autocomplete text input with a filtered suggestion list — native <datalist> for the simple case, ARIA combobox pattern for full control.
Use this when
You need a text input that suggests and filters options as the user types — country pickers, tag inputs, search-with-suggestions. Start with the native variant (<input list> + <datalist>): zero JS, screen-reader support for free. Upgrade to the custom variant only when you need styled options, async option loading, or multi-select. If users pick from a short fixed list and never type, bail and use a plain <select> with cia.select-base instead.
Structure (raw HTML)
Native variant (<datalist>)
<div data-cia-recipe="combobox">
<label for="my-fruit" data-slot="label">Favorite fruit</label>
<input id="my-fruit" data-slot="input" type="text" list="my-fruit-options" autocomplete="off" />
<datalist id="my-fruit-options">
<option value="Apple"></option>
<option value="Banana"></option>
<option value="Cherry"></option>
</datalist>
</div>
Notes on the markup:
- The browser owns the suggestion popup: filtering, keyboard nav, and screen-reader announcements all work natively
autocomplete="off"stops the browser's form-autofill popup from fighting the datalist popup- The popup itself is not styleable — if the design requires styled options, use the custom variant below
Custom variant (ARIA combobox pattern)
<div class="my-combobox" data-cia-recipe="combobox">
<label for="my-combo-input" data-slot="label">Favorite fruit</label>
<input
id="my-combo-input"
data-slot="input"
type="text"
role="combobox"
aria-expanded="false"
aria-controls="my-combo-listbox"
aria-autocomplete="list"
autocomplete="off"
spellcheck="false"
/>
<ul id="my-combo-listbox" data-slot="listbox" role="listbox" aria-label="Suggestions" hidden>
<li id="my-combo-opt-0" role="option">Apple</li>
<li id="my-combo-opt-1" role="option" data-active>Banana</li>
<li id="my-combo-opt-2" role="option" aria-selected="true">Cherry</li>
</ul>
</div>
Notes on the markup:
- The
<input>keeps DOM focus the whole time — the listbox is driven byaria-activedescendanton the input pointing at the active option'sid(no rovingtabindex) aria-expandedmirrors whether the listbox is visible;aria-controlsties input to listboxaria-autocomplete="list"tells screen readers a filtered list appears as the user typesdata-activemarks the optionaria-activedescendantcurrently points at (keyboard highlight);aria-selected="true"marks the committed value — they are different states and style differently- Option
ids must be stable per render soaria-activedescendantcan reference them
Styling (cia mixins)
// MyCombobox.module.scss — component stylesheet, so import the zero-emit barrel.
@use 'css-is-awesome/api' as cia;
.my-combobox {
position: relative;
[data-slot="label"] { @include cia.label-base; }
[data-slot="input"] {
@include cia.input-base;
inline-size: 100%;
}
[data-slot="listbox"] {
@include cia.popover-base($p: 1, $max-width: none);
position: absolute;
inset-block-start: calc(100% + #{cia.space(1)});
inset-inline: 0;
margin: 0;
list-style: none;
max-block-size: 16rem;
overflow-y: auto;
}
[role="option"] {
@include cia.dropdown-item;
border-radius: cia.radius(sm);
}
/* Keyboard highlight — where aria-activedescendant points */
[role="option"][data-active] {
background: cia.color(interactive-hover);
}
/* Committed value */
[role="option"][aria-selected="true"] {
font-weight: cia.font-weight(medium);
background: cia.color(interactive-active);
}
}
The native variant only needs [data-slot="label"] + [data-slot="input"] — the browser popup ignores author CSS. cia.dropdown-item already provides :hover (pointer highlight); [data-active] layers the keyboard highlight on top so mouse and keyboard states stay independent.
Interactivity
Native variant: zero JS. The browser filters, navigates, commits, and announces.
Custom variant: this is the rare recipe where real JS is required — ARIA state doesn't update itself. The consumer script owns five jobs, each a few lines:
- Filter options against the input value on every
inputevent (re-open the listbox, reset the active index) - Open/close — set
hiddenon the listbox and mirror it toaria-expandedon the input - Track the active option — move an index with Arrow keys, set
aria-activedescendantto that option'sid, toggledata-active - Commit — on Enter or option click, write the option's text into the input, set
aria-selected, close - Dismiss — Esc closes the listbox (a second Esc clears the input); blur closes
Keyboard map (per the WAI-ARIA APG combobox pattern):
| Key | Behavior |
|---|---|
ArrowDown |
Open the listbox if closed; move active option down |
ArrowUp |
Move active option up |
Enter |
Commit the active option, close the listbox |
Escape |
Close the listbox; if already closed, clear the input |
Home / End |
Move the text caret to start/end of the input (native — DOM focus never leaves the input) |
| Printable keys | Type into the input; list re-filters |
Edge cases:
- Option
mousedownvs inputblur: clicking an option fires the input'sblurfirst, which closes the listbox beforeclicklands. Commit onmousedown+preventDefault()(the framework examples below all do this). - SSR: render with
hiddenon the listbox andaria-expanded="false"— the closed state is the correct server snapshot. - Async options: while loading, keep
aria-expanded="true"and render a single non-interactive<li>withoutrole="option"("Loading…") so it isn't keyboard-reachable.
Pairs well with (but requires none of): Downshift, Headless UI Combobox, Zag.js combobox — they own jobs 1-5 and you keep this recipe's styling section verbatim.
A11y checklist
- Input has
role="combobox",aria-expanded,aria-controlspointing at the listboxid(APG Combobox Pattern) -
aria-activedescendanton the input tracks the active option; DOM focus stays on the input - Every suggestion has
role="option"and a stableid; the listbox hasrole="listbox"with an accessible name -
ArrowDown/ArrowUpmove the active option;Entercommits;Esccloses (APG keyboard interaction) -
Home/Endkeep their native text-editing behavior (caret to start/end) — do not hijack them for option nav in an editable combobox - Committed option carries
aria-selected="true" - Visible label is associated via
for/id(WCAG 2.2 SC 1.3.1 Info and Relationships) - Keyboard highlight (
[data-active]) meets non-text contrast against the listbox surface (WCAG 2.2 SC 1.4.11) - Touch targets: options inherit ≥24px height from
cia.dropdown-itempadding (WCAG 2.2 SC 2.5.8 Target Size)
Framework examples
All four examples implement the same spec: filter-as-you-type over a static list, full APG keyboard map, commit on Enter/click.
React
"use client";
import { useId, useState } from "react";
import styles from "./MyCombobox.module.scss";
const FRUITS = ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Mango", "Peach", "Pear"];
export default function MyCombobox() {
const id = useId();
const [value, setValue] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const matches = FRUITS.filter((f) => f.toLowerCase().includes(value.toLowerCase()));
const expanded = open && matches.length > 0;
const commit = (option: string) => {
setValue(option);
setOpen(false);
setActive(-1);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
setActive((a) => Math.min(a + 1, matches.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(a - 1, 0));
} else if (e.key === "Enter" && expanded && active >= 0) {
e.preventDefault();
commit(matches[active]);
} else if (e.key === "Escape") {
if (open) { setOpen(false); setActive(-1); } else setValue("");
}
// Home / End fall through: native caret movement
};
return (
<div className={styles.myCombobox}>
<label htmlFor={`${id}-input`} data-slot="label">Favorite fruit</label>
<input
id={`${id}-input`}
data-slot="input"
type="text"
role="combobox"
aria-expanded={expanded}
aria-controls={`${id}-listbox`}
aria-autocomplete="list"
aria-activedescendant={active >= 0 ? `${id}-opt-${active}` : undefined}
autoComplete="off"
spellCheck={false}
value={value}
onChange={(e) => { setValue(e.target.value); setOpen(true); setActive(-1); }}
onKeyDown={onKeyDown}
onBlur={() => setOpen(false)}
/>
<ul id={`${id}-listbox`} data-slot="listbox" role="listbox" aria-label="Suggestions" hidden={!expanded}>
{matches.map((fruit, i) => (
<li
key={fruit}
id={`${id}-opt-${i}`}
role="option"
aria-selected={fruit === value}
data-active={i === active || undefined}
onMouseDown={(e) => { e.preventDefault(); commit(fruit); }}
>
{fruit}
</li>
))}
</ul>
</div>
);
}
Vue
<script setup>
import { computed, ref } from "vue";
const FRUITS = ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Mango", "Peach", "Pear"];
const value = ref("");
const open = ref(false);
const active = ref(-1);
const matches = computed(() =>
FRUITS.filter((f) => f.toLowerCase().includes(value.value.toLowerCase()))
);
const expanded = computed(() => open.value && matches.value.length > 0);
function commit(option) {
value.value = option;
open.value = false;
active.value = -1;
}
function onInput() {
open.value = true;
active.value = -1;
}
function onKeyDown(e) {
if (e.key === "ArrowDown") {
e.preventDefault();
open.value = true;
active.value = Math.min(active.value + 1, matches.value.length - 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
active.value = Math.max(active.value - 1, 0);
} else if (e.key === "Enter" && expanded.value && active.value >= 0) {
e.preventDefault();
commit(matches.value[active.value]);
} else if (e.key === "Escape") {
if (open.value) { open.value = false; active.value = -1; } else value.value = "";
}
}
</script>
<template>
<div class="my-combobox">
<label for="combo-input" data-slot="label">Favorite fruit</label>
<input
id="combo-input"
data-slot="input"
type="text"
role="combobox"
:aria-expanded="expanded"
aria-controls="combo-listbox"
aria-autocomplete="list"
:aria-activedescendant="active >= 0 ? `combo-opt-${active}` : undefined"
autocomplete="off"
spellcheck="false"
v-model="value"
@input="onInput"
@keydown="onKeyDown"
@blur="open = false"
/>
<ul id="combo-listbox" data-slot="listbox" role="listbox" aria-label="Suggestions" :hidden="!expanded">
<li
v-for="(fruit, i) in matches"
:key="fruit"
:id="`combo-opt-${i}`"
role="option"
:aria-selected="fruit === value"
:data-active="i === active ? '' : undefined"
@mousedown.prevent="commit(fruit)"
>
{{ fruit }}
</li>
</ul>
</div>
</template>
Svelte
<script>
const FRUITS = ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Mango", "Peach", "Pear"];
let value = "";
let open = false;
let active = -1;
$: matches = FRUITS.filter((f) => f.toLowerCase().includes(value.toLowerCase()));
$: expanded = open && matches.length > 0;
function commit(option) {
value = option;
open = false;
active = -1;
}
function onKeyDown(e) {
if (e.key === "ArrowDown") {
e.preventDefault();
open = true;
active = Math.min(active + 1, matches.length - 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
active = Math.max(active - 1, 0);
} else if (e.key === "Enter" && expanded && active >= 0) {
e.preventDefault();
commit(matches[active]);
} else if (e.key === "Escape") {
if (open) { open = false; active = -1; } else value = "";
}
}
</script>
<div class="my-combobox">
<label for="combo-input" data-slot="label">Favorite fruit</label>
<input
id="combo-input"
data-slot="input"
type="text"
role="combobox"
aria-expanded={expanded}
aria-controls="combo-listbox"
aria-autocomplete="list"
aria-activedescendant={active >= 0 ? `combo-opt-${active}` : undefined}
autocomplete="off"
spellcheck="false"
bind:value
on:input={() => { open = true; active = -1; }}
on:keydown={onKeyDown}
on:blur={() => (open = false)}
/>
<ul id="combo-listbox" data-slot="listbox" role="listbox" aria-label="Suggestions" hidden={!expanded}>
{#each matches as fruit, i (fruit)}
<li
id={`combo-opt-${i}`}
role="option"
aria-selected={fruit === value}
data-active={i === active ? "" : undefined}
on:mousedown|preventDefault={() => commit(fruit)}
>
{fruit}
</li>
{/each}
</ul>
</div>
Vanilla (Web Component)
class MyCombobox extends HTMLElement {
static idSeq = 0;
connectedCallback() {
this._options = (this.getAttribute("options") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
this._uid = `my-combo-${MyCombobox.idSeq++}`;
this._active = -1;
this._open = false;
this.classList.add("my-combobox");
this.innerHTML = `
<label for="${this._uid}-input" data-slot="label">${this.getAttribute("label") ?? "Choose"}</label>
<input id="${this._uid}-input" data-slot="input" type="text" role="combobox"
aria-expanded="false" aria-controls="${this._uid}-listbox"
aria-autocomplete="list" autocomplete="off" spellcheck="false" />
<ul id="${this._uid}-listbox" data-slot="listbox" role="listbox" aria-label="Suggestions" hidden></ul>
`;
this._input = this.querySelector("input");
this._listbox = this.querySelector("ul");
this._input.addEventListener("input", () => { this._open = true; this._active = -1; this._render(); });
this._input.addEventListener("keydown", (e) => this._onKeyDown(e));
this._input.addEventListener("blur", () => { this._open = false; this._render(); });
}
get _matches() {
const q = this._input.value.toLowerCase();
return this._options.filter((o) => o.toLowerCase().includes(q));
}
_commit(option) {
this._input.value = option;
this._open = false;
this._active = -1;
this._render();
this.dispatchEvent(new CustomEvent("change", { detail: option }));
}
_onKeyDown(e) {
const matches = this._matches;
if (e.key === "ArrowDown") {
e.preventDefault();
this._open = true;
this._active = Math.min(this._active + 1, matches.length - 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
this._active = Math.max(this._active - 1, 0);
} else if (e.key === "Enter" && this._open && this._active >= 0) {
e.preventDefault();
this._commit(matches[this._active]);
return;
} else if (e.key === "Escape") {
if (this._open) { this._open = false; this._active = -1; } else this._input.value = "";
} else {
return; // Home/End + printable keys: native behavior
}
this._render();
}
_render() {
const matches = this._matches;
const expanded = this._open && matches.length > 0;
this._input.setAttribute("aria-expanded", String(expanded));
this._listbox.hidden = !expanded;
this._listbox.innerHTML = "";
matches.forEach((option, i) => {
const li = document.createElement("li");
li.id = `${this._uid}-opt-${i}`;
li.setAttribute("role", "option");
li.setAttribute("aria-selected", String(option === this._input.value));
if (i === this._active) li.setAttribute("data-active", "");
li.textContent = option;
li.addEventListener("mousedown", (e) => { e.preventDefault(); this._commit(option); });
this._listbox.appendChild(li);
});
this._input.setAttribute("aria-activedescendant",
this._active >= 0 ? `${this._uid}-opt-${this._active}` : "");
}
}
customElements.define("my-combobox", MyCombobox);
// usage in HTML:
// <my-combobox label="Favorite fruit" options="Apple, Banana, Cherry, Mango"></my-combobox>
// <script>document.querySelector('my-combobox').addEventListener('change', (e) => console.log(e.detail));</script>
Variants
Select-only (no free text)
Add readonly semantics: ignore printable keys, open on click/ArrowDown, and skip the filter (always show all options). Keep role="combobox" — this is the APG "select-only combobox" sub-pattern. Consider whether a styled <select> with cia.select-base does the job first.
Async / remote options
Replace the static array with a fetch debounced ~200ms. While loading, keep the listbox open with one non-interactive <li> ("Searching…") that has no role="option". Announce result counts with a visually-hidden aria-live="polite" region ("8 results available").
Multi-select (tag input)
Committed values render as removable chips before the input; the input clears after each commit instead of taking the value. Set aria-selected="true" on every committed option still in the list. This is a large step up in complexity — this is the point where a headless engine (Downshift, Zag.js) earns its weight.
Pitfalls
Don't hijack Home/End. In an editable combobox they must move the text caret. Hijacking them for first/last option breaks text editing and contradicts the APG.
aria-activedescendantrequires same-documentidreferences. It cannot point across shadow DOM boundaries — the Web Component above deliberately uses light DOM.Blur-close race: closing the listbox on input
blurkills optionclickhandlers. Commit onmousedown+preventDefault()(all examples above) or userelatedTargetchecks.<datalist>styling is a dead end. No browser lets you style the native popup. Don't burn time on it — if the design needs styled options, that's the signal to move to the custom variant.Filtering resets the active index. After re-filtering, old indices point at different options. Reset
activeto-1on everyinputevent (all examples above do).Scroll the active option into view when the list overflows:
option.scrollIntoView({ block: "nearest" })after moving the active index — omitted from the examples for brevity, needed in production with long lists.Phones: cap the listbox, thumb-size the options. The
max-block-size: 16rem; overflow-y: autoin the Styling section is what keeps a long list from running past the bottom of a small viewport — don't delete it when trimming. Andcia.dropdown-itemis padded for pointers (≥24px, the WCAG minimum); at touch widths, bump options to a comfortable 44px thumb target:[role="option"] { @include cia.media-down(md) { min-block-size: 44px; } }
Related recipes
dialog— the other half of the command-palette pattern- (planned, Week 2)
command-palette.md— Cmd+K palette =<dialog>+ this combobox's input layer; it links here for the input, doesn't redefine it - (planned, Week 2)
datepicker.md— another "native first, custom when needed" input recipe