Overview
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:
- Select-only (list autocomplete): The user must choose from the predefined list. Typing filters the options but doesn't allow freeform values. Think: country selector, user mention picker.
- Free-form (inline autocomplete): The user can type any value or select from suggestions. Think: search bars, tag inputs, address fields.
This distinction is critical for implementation — it determines validation rules, ARIA attributes, and user expectations.
When to use a Combobox:
- Selecting from large datasets (countries, users, products) where a Select would be too long
- When users might know the value and want to type it directly instead of scrolling
- Search-as-you-type interfaces with suggestions
- Tag or mention pickers where the list is dynamic or server-driven
When NOT to use a Combobox:
- Fewer than ~10 static options — use a Select or Radio Buttons for simplicity
- Binary or simple choices — use a Switch or Checkbox
- Full search engines — use a dedicated search component with results pages
- When options don't benefit from filtering (e.g., "Yes/No") — simpler controls suffice
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).
Variants
Behavioral Variants
| 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 |
Visual Variants
| 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 Variants
| 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 |
Dropdown Behavior
The dropdown (listbox) should appear when:
- The input receives focus and has existing text (show matching results)
- The user types at least 1 character (or 0 for small lists)
- The user clicks the dropdown arrow/chevron trigger
It should close when:
- An option is selected
- The user presses
Escape - Focus moves outside the combobox
- The user presses
Tab(focus moves out, optionally selecting the highlighted item)
Properties
Combobox Properties
| 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 Mappings
Design Token Mappings
| 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 |
States
Interactive States
| 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. |
Dropdown Animation
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).
Accessibility
Accessibility Requirements
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):
- The text input has
role="combobox"(implied by the pattern). aria-expanded="true|false"— whether the dropdown is visible.aria-controls="[listbox-id]"— points to the dropdown'sid.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.- The dropdown has
role="listbox". - Each option has
role="option"witharia-selected="true|false". - For grouped options, use
role="group"witharia-labelon 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.- Typing: Filters the list. The input retains focus at all times — focus never moves into the listbox.
Screen Reader Announcements:
- When the dropdown opens, the screen reader should announce the number of available options: "5 results available."
- As the user arrows through options,
aria-activedescendantupdates, causing the screen reader to announce the focused option's text. - On selection, announce the selected value.
- Use an
aria-live="polite"region to announce dynamic result counts as the user types.
Label Association (WCAG 1.3.1):
- The combobox input must have a visible label via
<label for="...">oraria-labelledby. - If there's helper text or error text, associate it with
aria-describedby.
Focus Management (WCAG 2.4.7 / 2.4.13):
- Focus must remain on the input at all times. The listbox options are highlighted via
aria-activedescendant, not by moving DOM focus. - The focused input must have a visible focus ring.
- When the dropdown closes, focus stays on the input.
Non-Text Contrast (WCAG 1.4.11):
- The input border must have ≥3:1 contrast against the background in all states (rest, focus, error).
- The highlighted option's background must be distinguishable — verify with Contrast Checker.
Multi-Select Chip Accessibility:
- Each chip should be an interactive element with
role="option"or a button for removal. - The remove button needs an accessible name:
aria-label="Remove [value]". - Users should be able to navigate chips with
Arrow Left/Arrow Rightand remove withBackspaceorDelete.
Common Implementation Mistakes:
- Moving DOM focus into the listbox instead of using
aria-activedescendant. - Missing
aria-expandedor not toggling it. - Not announcing result counts to screen readers.
- Allowing mouse click on an option without updating
aria-activedescendantfirst. - Not closing the dropdown on
EscapeorTab.
Usage Guidelines
Do's
- Debounce async searches — 200–300ms delay prevents excessive API calls while feeling responsive.
- Show a loading indicator for async operations — a spinner inside the dropdown, not replacing the input content.
- Bold matching text in options to show the user why each result matches their query.
- Provide an empty state message — "No results for [query]" is more helpful than an empty dropdown.
- Pre-populate common/recent selections at the top of the list before the user types.
- Limit visible options to ~8–10 in the dropdown, with scrolling for more. Don't render 500 options in the DOM — use virtualization.
- Allow keyboard-only operation — every action possible with a mouse must be possible with the keyboard.
Don'ts
- Don't require exact spelling — implement fuzzy matching or at least case-insensitive substring matching.
- Don't auto-select the first option unless you're implementing inline autocomplete. For list autocomplete, highlight but don't select until the user explicitly confirms.
- Don't clear the input on blur if the user typed a valid value — this is frustrating. If the typed text exactly matches an option, auto-select it on blur.
- Don't block the UI while loading — show the input immediately and load options asynchronously.
- Don't use a combobox for tiny lists — 5 options don't need search. Use a Select or Radio Buttons.
- Don't forget mobile — ensure the dropdown doesn't overflow the viewport. Consider a bottom sheet pattern on small screens.
Filtering Best Practices
- Default to case-insensitive substring matching (
"york"matches"New York"). - For large datasets, consider server-side filtering to avoid loading thousands of options client-side.
- For known formats (phone numbers, postal codes), apply input masking alongside filtering.
- Show recently selected values at the top of the unfiltered list for returning users.
Code Snippets
<!-- 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>
);
}Design Systems
Design System Implementations
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.