Loading…
Loading…
Combines a text input with a filterable dropdown list for enhanced selection.
The Combobox (also called autocomplete, typeahead, or search select) combines a text input with a filterable dropdown list. It lets users either type to search/filter from a list of options or select from the full list directly — bridging the gap between a Text Input and a Select.
Comboboxes solve a scaling problem that traditional selects can't handle. A select with 10 options is manageable; a select with 200 countries, 1,000 cities, or 10,000 products is unusable. The combobox adds a text filter that narrows the list as the user types, making large datasets navigable without overwhelming the interface.
There are two fundamental combobox behaviors:
This distinction is critical for implementation — it determines validation rules, ARIA attributes, and user expectations.
When to use a Combobox:
When NOT to use a Combobox:
Use the Contrast Checker to ensure that highlighted/selected options in the dropdown maintain sufficient contrast against the list background, especially for the currently-focused item's background color (WCAG 1.4.11).
| Variant | User Can Type Custom Value? | ARIA Pattern | Use Case |
|---|---|---|---|
| Autocomplete (list) | No — must select from list | aria-autocomplete="list" | Country picker, user selector, predefined tags |
| Autocomplete (inline) | Suggestion completes in the input | aria-autocomplete="inline" | Browser URL bar, IDE code completion |
| Autocomplete (both) | Inline + list combined | aria-autocomplete="both" | Email clients, address fields |
| Free-form | Yes — any value accepted | aria-autocomplete="list" + no validation | Search bars, tag creation |
| Variant | Description | Common In |
|---|---|---|
| Single-select | One value selected, displayed in the input field. | Country pickers, assignee fields |
| Multi-select | Multiple values shown as chips/tags inside the input. | Tag pickers, multi-assignee, categories |
| Grouped | Options are organized into labeled sections (<optgroup> equivalent). | "Recent" + "All users," categorized products |
| Async / Remote | Options load from a server as the user types. Loading spinner shown. | Search engines, API-backed entity selectors |
| Creatable | If no match found, an option to "Create [typed value]" appears. | Tag management, CRM entry creation |
| Size | Height | Font Size | Use Case |
|---|---|---|---|
| sm | 32px | 13px | Dense forms, table cells, filters |
| md | 40px | 14px | Default for most forms |
| lg | 48px | 16px | Landing pages, prominent search fields |
The dropdown (listbox) should appear when:
It should close when:
EscapeTab (focus moves out, optionally selecting the highlighted item)| Property | Type | Default | Description |
|---|---|---|---|
value | string | string[] | — | Current selected value(s) (controlled) |
defaultValue | string | string[] | — | Initial value(s) (uncontrolled) |
onChange | (value: string | string[]) => void | — | Fired when selection changes |
onInputChange | (query: string) => void | — | Fired as the user types — use for filtering or async fetching |
options | Option[] | [] | The list of selectable options: { value: string; label: string; group?: string; disabled?: boolean } |
multiple | boolean | false | Allow multiple selections (renders chips) |
creatable | boolean | false | Allow creating new values not in the list |
async | boolean | false | Indicates options are fetched remotely; shows loading state |
loading | boolean | false | Shows a spinner in the dropdown during async loading |
placeholder | string | — | Placeholder text in the input |
disabled | boolean | false | Disables the entire combobox |
required | boolean | false | Required for form validation |
error | string | — | Error message displayed below |
emptyMessage | string | "No results" | Message shown when filtering yields no matches |
filterFn | (query: string, option: Option) => boolean | label includes query | Custom filter function |
maxSelections | number | — | Maximum allowed selections in multi mode |
clearable | boolean | true | Shows a clear button when a value is selected |
size | 'sm' | 'md' | 'lg' | 'md' | Input size variant |
| Token Category | Token Example | Combobox Usage |
|---|---|---|
| Color – Background | --color-white | Input and dropdown background |
| Color – Border | --color-neutral-300 | Input border (rest state) |
| Color – Border Focus | --color-primary-500 | Input border on focus |
| Color – Text | --color-neutral-900 | Input text and option text |
| Color – Placeholder | --color-neutral-400 | Placeholder text |
| Color – Highlight | --color-primary-50 | Focused/hovered option background in the dropdown |
| Color – Selected | --color-primary-100 | Currently selected option background |
| Color – Error | --color-error-600 | Error border and message color |
| Color – Chip BG | --color-primary-100 | Multi-select chip background |
| Color – Chip Text | --color-primary-800 | Multi-select chip text |
| Spacing – Padding | --space-3 (12px) | Input horizontal padding |
| Spacing – Option Padding | --space-2 × --space-3 | Vertical × horizontal padding per option |
| Spacing – Dropdown Gap | --space-1 (4px) | Gap between input and dropdown |
| Border Radius | --radius-md (8px) | Input and dropdown corners. Preview with Border Radius Generator. |
| Shadow | --shadow-lg | Dropdown elevation shadow |
| Typography | --font-size-sm, --font-weight-normal | Option text styling |
| Z-Index | --z-dropdown (50) | Dropdown layering |
| Transition | --duration-fast | Dropdown open/close, highlight transitions |
| State | Visual Treatment | Behavior |
|---|---|---|
| Rest | Default input with neutral border, chevron/search icon on the right. | Awaiting interaction. |
| Focused | Primary border color, focus ring, dropdown may open. | Input is active, user can type. |
| Typing / Filtering | Options in dropdown update in real time. Matching text is bolded. | List filters as the user types. |
| Option Highlighted | Highlighted option has a background tint (--color-primary-50). | Arrow keys or mouse hover moves the highlight. |
| Option Selected | Checkmark icon or filled background on the selected option. Input shows the selected label. | Value is committed. |
| Multi-Select Active | Selected values appear as removable chips/tags inside the input. Input still allows typing. | Each chip has a remove (✕) button. |
| Loading | Spinner replaces the dropdown content or appears below options. | Async options are being fetched. |
| Empty State | "No results found" message in the dropdown. Optional "Create [value]" action for creatable mode. | Query matched no options. |
| Disabled | Muted background, no interaction, cursor not-allowed. | Not interactive. |
| Error | Red border, error icon, error message below. | Validation failed. |
| Read-only | Value displayed but input is not editable, no dropdown trigger. | View-only context. |
The dropdown should animate open with a subtle scale + opacity transition — transform: scaleY(0.95) to scaleY(1) with opacity: 0 to 1 over 100–150ms. Avoid slide-down animations that feel sluggish. The dropdown should feel like it "pops" into existence.
For closing, either match the open animation in reverse or simply remove without animation (instant close feels natural because the user just made a selection).
The combobox is one of the most complex ARIA patterns. WAI-ARIA 1.2 defines the combobox role with specific requirements that are frequently implemented incorrectly.
ARIA Roles and Properties (WCAG 4.1.2 Name, Role, Value):
role="combobox" (implied by the pattern).aria-expanded="true|false" — whether the dropdown is visible.aria-controls="[listbox-id]" — points to the dropdown's id.aria-autocomplete="list|inline|both|none" — describes the autocomplete behavior.aria-activedescendant="[option-id]" — points to the currently highlighted option. This is how screen readers know which option is focused without moving DOM focus out of the input.role="listbox".role="option" with aria-selected="true|false".role="group" with aria-label on the group container.Keyboard Interaction (WCAG 2.1.1 Keyboard):
Arrow Down: Open the dropdown (if closed) or move highlight to the next option.Arrow Up: Move highlight to the previous option.Enter: Select the highlighted option and close the dropdown.Escape: Close the dropdown without selecting. If the dropdown is already closed, clear the input.Home / End: Move to the first/last option in the list.Screen Reader Announcements:
aria-activedescendant updates, causing the screen reader to announce the focused option's text.aria-live="polite" region to announce dynamic result counts as the user types.Label Association (WCAG 1.3.1):
<label for="..."> or aria-labelledby.aria-describedby.Focus Management (WCAG 2.4.7 / 2.4.13):
aria-activedescendant, not by moving DOM focus.Non-Text Contrast (WCAG 1.4.11):
Multi-Select Chip Accessibility:
role="option" or a button for removal.aria-label="Remove [value]".Arrow Left/Arrow Right and remove with Backspace or Delete.Common Implementation Mistakes:
aria-activedescendant.aria-expanded or not toggling it.aria-activedescendant first.Escape or Tab."york" matches "New York").<!-- Combobox: Country Selector -->
<div class="combobox" data-state="closed">
<label for="country-input" class="combobox__label">Country</label>
<div class="combobox__wrapper">
<input
id="country-input"
type="text"
role="combobox"
aria-expanded="false"
aria-controls="country-listbox"
aria-autocomplete="list"
aria-activedescendant=""
autocomplete="off"
placeholder="Search countries…"
class="combobox__input"
/>
<button
type="button"
tabindex="-1"
aria-label="Toggle country list"
class="combobox__trigger"
>
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
</div>
<ul
id="country-listbox"
role="listbox"
aria-label="Countries"
class="combobox__listbox"
hidden
>
<li role="option" id="opt-us" aria-selected="false">United States</li>
<li role="option" id="opt-uk" aria-selected="false">United Kingdom</li>
<li role="option" id="opt-de" aria-selected="false">Germany</li>
<li role="option" id="opt-fr" aria-selected="false">France</li>
<li role="option" id="opt-jp" aria-selected="false">Japan</li>
</ul>
<div class="combobox__live" aria-live="polite" aria-atomic="true" class="sr-only"></div>
</div>
<style>
.combobox__label {
display: block;
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 0.375rem;
color: var(--color-neutral-900);
}
.combobox__wrapper {
position: relative;
display: flex;
align-items: center;
}
.combobox__input {
width: 100%;
height: 40px;
padding: 0 2.25rem 0 0.75rem;
border: 1px solid var(--color-neutral-300);
border-radius: var(--radius-md, 8px);
font-size: 0.875rem;
color: var(--color-neutral-900);
background: var(--color-white);
outline: none;
transition: border-color 150ms ease;
}
.combobox__input:focus {
border-color: var(--color-primary-500);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
}
.combobox__trigger {
position: absolute;
right: 0.5rem;
background: none;
border: none;
cursor: pointer;
color: var(--color-neutral-500);
padding: 0.25rem;
}
.combobox__listbox {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 240px;
overflow-y: auto;
background: var(--color-white);
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-md, 8px);
box-shadow: var(--shadow-lg);
list-style: none;
padding: 0.25rem;
margin: 0;
z-index: var(--z-dropdown, 50);
}
.combobox__listbox [role="option"] {
padding: 0.5rem 0.75rem;
border-radius: var(--radius-sm, 4px);
cursor: pointer;
font-size: 0.875rem;
color: var(--color-neutral-800);
}
.combobox__listbox [role="option"][aria-selected="true"] {
background: var(--color-primary-50);
color: var(--color-primary-800);
}
.combobox__listbox [role="option"]:hover,
.combobox__listbox [role="option"].highlighted {
background: var(--color-neutral-100);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
</style>
<script>
// Minimal combobox behavior (production code should use a library)
const input = document.getElementById("country-input");
const listbox = document.getElementById("country-listbox");
const options = listbox.querySelectorAll("[role='option']");
const liveRegion = document.querySelector(".combobox__live");
let highlightIdx = -1;
input.addEventListener("focus", () => openListbox());
input.addEventListener("input", () => {
const q = input.value.toLowerCase();
let visible = 0;
options.forEach((opt) => {
const match = opt.textContent.toLowerCase().includes(q);
opt.hidden = !match;
if (match) visible++;
});
liveRegion.textContent = visible + " results available";
highlightIdx = -1;
openListbox();
});
input.addEventListener("keydown", (e) => {
const visibleOpts = [...options].filter((o) => !o.hidden);
if (e.key === "ArrowDown") {
e.preventDefault();
highlightIdx = Math.min(highlightIdx + 1, visibleOpts.length - 1);
updateHighlight(visibleOpts);
} else if (e.key === "ArrowUp") {
e.preventDefault();
highlightIdx = Math.max(highlightIdx - 1, 0);
updateHighlight(visibleOpts);
} else if (e.key === "Enter" && highlightIdx >= 0) {
e.preventDefault();
selectOption(visibleOpts[highlightIdx]);
} else if (e.key === "Escape") {
closeListbox();
}
});
function openListbox() {
listbox.hidden = false;
input.setAttribute("aria-expanded", "true");
}
function closeListbox() {
listbox.hidden = true;
input.setAttribute("aria-expanded", "false");
input.setAttribute("aria-activedescendant", "");
}
function updateHighlight(visibleOpts) {
options.forEach((o) => o.classList.remove("highlighted"));
if (visibleOpts[highlightIdx]) {
visibleOpts[highlightIdx].classList.add("highlighted");
input.setAttribute("aria-activedescendant", visibleOpts[highlightIdx].id);
visibleOpts[highlightIdx].scrollIntoView({ block: "nearest" });
}
}
function selectOption(opt) {
input.value = opt.textContent;
options.forEach((o) => o.setAttribute("aria-selected", "false"));
opt.setAttribute("aria-selected", "true");
closeListbox();
}
options.forEach((opt) => {
opt.addEventListener("click", () => selectOption(opt));
});
document.addEventListener("click", (e) => {
if (!e.target.closest(".combobox")) closeListbox();
});
</script>import React, { useState, useRef, useEffect, useId, useCallback } from "react";
interface Option {
value: string;
label: string;
disabled?: boolean;
}
interface ComboboxProps {
options: Option[];
value?: string;
onChange?: (value: string) => void;
onInputChange?: (query: string) => void;
placeholder?: string;
label: string;
error?: string;
disabled?: boolean;
loading?: boolean;
emptyMessage?: string;
clearable?: boolean;
}
export function Combobox({
options,
value,
onChange,
onInputChange,
placeholder = "Search…",
label,
error,
disabled = false,
loading = false,
emptyMessage = "No results found",
clearable = true,
}: ComboboxProps) {
const id = useId();
const listboxId = `${id}-listbox`;
const errorId = error ? `${id}-error` : undefined;
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [highlightIndex, setHighlightIndex] = useState(-1);
const [announcement, setAnnouncement] = useState("");
const filtered = options.filter((o) =>
o.label.toLowerCase().includes(query.toLowerCase())
);
const selectedOption = options.find((o) => o.value === value);
const open = useCallback(() => {
if (!disabled) setIsOpen(true);
}, [disabled]);
const close = useCallback(() => {
setIsOpen(false);
setHighlightIndex(-1);
}, []);
const select = useCallback(
(opt: Option) => {
onChange?.(opt.value);
setQuery(opt.label);
close();
},
[onChange, close]
);
useEffect(() => {
if (isOpen) {
setAnnouncement(`${filtered.length} result${filtered.length !== 1 ? "s" : ""} available`);
}
}, [filtered.length, isOpen]);
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (!isOpen) { open(); return; }
setHighlightIndex((i) => Math.min(i + 1, filtered.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setHighlightIndex((i) => Math.max(i - 1, 0));
break;
case "Enter":
e.preventDefault();
if (highlightIndex >= 0 && filtered[highlightIndex]) {
select(filtered[highlightIndex]);
}
break;
case "Escape":
close();
break;
}
};
const activeDescendant =
highlightIndex >= 0 && filtered[highlightIndex]
? `${id}-opt-${filtered[highlightIndex].value}`
: undefined;
return (
<div style={{ position: "relative" }}>
<label htmlFor={`${id}-input`} style={{ display: "block", fontWeight: 600, fontSize: "0.875rem", marginBottom: 4 }}>
{label}
</label>
<div style={{ position: "relative", display: "flex", alignItems: "center" }}>
<input
ref={inputRef}
id={`${id}-input`}
type="text"
role="combobox"
aria-expanded={isOpen}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeDescendant}
aria-invalid={!!error}
aria-describedby={errorId}
autoComplete="off"
disabled={disabled}
placeholder={selectedOption?.label ?? placeholder}
value={query}
onChange={(e) => {
setQuery(e.target.value);
onInputChange?.(e.target.value);
open();
setHighlightIndex(-1);
}}
onFocus={open}
onKeyDown={handleKeyDown}
style={{
width: "100%",
height: 40,
padding: "0 2.25rem 0 0.75rem",
border: `1px solid ${error ? "var(--color-error-600)" : "var(--color-neutral-300)"}`,
borderRadius: "var(--radius-md, 8px)",
fontSize: "0.875rem",
outline: "none",
}}
/>
{clearable && value && (
<button
type="button"
aria-label="Clear selection"
onClick={() => { onChange?.(""); setQuery(""); inputRef.current?.focus(); }}
style={{ position: "absolute", right: "2rem", background: "none", border: "none", cursor: "pointer", color: "var(--color-neutral-400)" }}
>
✕
</button>
)}
</div>
{isOpen && (
<ul
ref={listRef}
id={listboxId}
role="listbox"
aria-label={label}
style={{
position: "absolute",
top: "calc(100% + 4px)",
left: 0,
right: 0,
maxHeight: 240,
overflowY: "auto",
background: "var(--color-white)",
border: "1px solid var(--color-neutral-200)",
borderRadius: "var(--radius-md, 8px)",
boxShadow: "var(--shadow-lg)",
listStyle: "none",
padding: "0.25rem",
margin: 0,
zIndex: 50,
}}
>
{loading ? (
<li style={{ padding: "0.75rem", textAlign: "center", color: "var(--color-neutral-500)" }}>Loading…</li>
) : filtered.length === 0 ? (
<li style={{ padding: "0.75rem", textAlign: "center", color: "var(--color-neutral-500)" }}>{emptyMessage}</li>
) : (
filtered.map((opt, idx) => (
<li
key={opt.value}
id={`${id}-opt-${opt.value}`}
role="option"
aria-selected={opt.value === value}
aria-disabled={opt.disabled}
onClick={() => !opt.disabled && select(opt)}
onMouseEnter={() => setHighlightIndex(idx)}
style={{
padding: "0.5rem 0.75rem",
borderRadius: "var(--radius-sm, 4px)",
cursor: opt.disabled ? "not-allowed" : "pointer",
background: idx === highlightIndex ? "var(--color-primary-50)" : opt.value === value ? "var(--color-neutral-50)" : "transparent",
opacity: opt.disabled ? 0.5 : 1,
fontSize: "0.875rem",
}}
>
{opt.label}
</li>
))
)}
</ul>
)}
<div aria-live="polite" aria-atomic="true" style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0,0,0,0)" }}>
{announcement}
</div>
{error && (
<p id={errorId} role="alert" style={{ color: "var(--color-error-600)", fontSize: "0.8125rem", marginTop: 4 }}>{error}</p>
)}
</div>
);
}Material Design 3 calls this the "Exposed Dropdown Menu" for select-only mode and provides "Search Bar" patterns for free-form autocomplete. MUI's Autocomplete component is one of the most full-featured implementations available: it supports single/multi-select, freeSolo mode (creatable), grouped options, async loading, custom rendering via renderOption, and virtualization via ListboxComponent. Material's visual treatment includes a text field with an embedded dropdown arrow, a ripple on option hover, and chip rendering for multi-select.
Ant Design provides AutoComplete (for free-form) and Select with showSearch (for list autocomplete). Ant's Select supports mode="multiple" or mode="tags" (creatable multi-select), filterOption for custom matching, onSearch for async fetching, labelInValue for returning { value, label } objects, and virtual scrolling for lists exceeding 100 items. The dropdown animation is a slide-down with spring easing.
Radix UI provides a Combobox primitive (introduced later in their roadmap). For earlier versions, developers combine Popover + custom listbox. cmdk (⌘K) — a popular unstyled combobox library by Pacocoursey — is often used alongside Radix for command palettes and search-driven comboboxes.
Downshift (by Kent C. Dodds) is the most popular headless combobox library for React. It provides useCombobox and useMultipleSelection hooks with complete ARIA compliance, keyboard navigation, and state management. It handles the gnarly edge cases (scroll into view, blur behavior, mobile keyboards) that trip up custom implementations.
Headless UI (Tailwind Labs) offers a Combobox component with Combobox.Input, Combobox.Options, and Combobox.Option. It supports single and multi-select (multiple prop), nullable values, and exposes render props (active, selected, disabled) for styling. Filtering is your responsibility — pass filtered options as children.
React Aria (Adobe) provides useComboBox with the most rigorous ARIA implementation available. It handles aria-activedescendant, live region announcements for result counts, mobile virtual keyboard interaction, and internationalized messages. It's the gold standard for accessibility.
React Select is the long-standing go-to React combobox library. It supports async loading (react-select/async), creatable options (react-select/creatable), multi-select with chip rendering, custom components (Option, SingleValue, Menu), and extensive theming. It's batteries-included but heavier than headless alternatives.
Verify that your highlighted option background meets the 3:1 non-text contrast ratio against the dropdown background using the Contrast Checker.