Skip to content

Combobox

Combines a text input with a filterable dropdown list for enhanced selection.

  • Autocomplete
  • Type-ahead
  • Search Select

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:

  1. 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.
  2. 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

VariantUser Can Type Custom Value?ARIA PatternUse Case
Autocomplete (list)No — must select from listaria-autocomplete="list"Country picker, user selector, predefined tags
Autocomplete (inline)Suggestion completes in the inputaria-autocomplete="inline"Browser URL bar, IDE code completion
Autocomplete (both)Inline + list combinedaria-autocomplete="both"Email clients, address fields
Free-formYes — any value acceptedaria-autocomplete="list" + no validationSearch bars, tag creation

Visual Variants

VariantDescriptionCommon In
Single-selectOne value selected, displayed in the input field.Country pickers, assignee fields
Multi-selectMultiple values shown as chips/tags inside the input.Tag pickers, multi-assignee, categories
GroupedOptions are organized into labeled sections (<optgroup> equivalent)."Recent" + "All users," categorized products
Async / RemoteOptions load from a server as the user types. Loading spinner shown.Search engines, API-backed entity selectors
CreatableIf no match found, an option to "Create [typed value]" appears.Tag management, CRM entry creation

Size Variants

SizeHeightFont SizeUse Case
sm32px13pxDense forms, table cells, filters
md40px14pxDefault for most forms
lg48px16pxLanding pages, prominent search fields

Dropdown Behavior

The dropdown (listbox) should appear when:

  1. The input receives focus and has existing text (show matching results)
  2. The user types at least 1 character (or 0 for small lists)
  3. The user clicks the dropdown arrow/chevron trigger

It should close when:

  1. An option is selected
  2. The user presses Escape
  3. Focus moves outside the combobox
  4. The user presses Tab (focus moves out, optionally selecting the highlighted item)

Properties

Combobox Properties

PropertyTypeDefaultDescription
valuestring | string[]—Current selected value(s) (controlled)
defaultValuestring | 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
optionsOption[][]The list of selectable options: { value: string; label: string; group?: string; disabled?: boolean }
multiplebooleanfalseAllow multiple selections (renders chips)
creatablebooleanfalseAllow creating new values not in the list
asyncbooleanfalseIndicates options are fetched remotely; shows loading state
loadingbooleanfalseShows a spinner in the dropdown during async loading
placeholderstring—Placeholder text in the input
disabledbooleanfalseDisables the entire combobox
requiredbooleanfalseRequired for form validation
errorstring—Error message displayed below
emptyMessagestring"No results"Message shown when filtering yields no matches
filterFn(query: string, option: Option) => booleanlabel includes queryCustom filter function
maxSelectionsnumber—Maximum allowed selections in multi mode
clearablebooleantrueShows a clear button when a value is selected
size'sm' | 'md' | 'lg''md'Input size variant

Token Mappings

Design Token Mappings

Token CategoryToken ExampleCombobox Usage
Color – Background--color-whiteInput and dropdown background
Color – Border--color-neutral-300Input border (rest state)
Color – Border Focus--color-primary-500Input border on focus
Color – Text--color-neutral-900Input text and option text
Color – Placeholder--color-neutral-400Placeholder text
Color – Highlight--color-primary-50Focused/hovered option background in the dropdown
Color – Selected--color-primary-100Currently selected option background
Color – Error--color-error-600Error border and message color
Color – Chip BG--color-primary-100Multi-select chip background
Color – Chip Text--color-primary-800Multi-select chip text
Spacing – Padding--space-3 (12px)Input horizontal padding
Spacing – Option Padding--space-2 × --space-3Vertical × 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-lgDropdown elevation shadow
Typography--font-size-sm, --font-weight-normalOption text styling
Z-Index--z-dropdown (50)Dropdown layering
Transition--duration-fastDropdown open/close, highlight transitions

States

Interactive States

StateVisual TreatmentBehavior
RestDefault input with neutral border, chevron/search icon on the right.Awaiting interaction.
FocusedPrimary border color, focus ring, dropdown may open.Input is active, user can type.
Typing / FilteringOptions in dropdown update in real time. Matching text is bolded.List filters as the user types.
Option HighlightedHighlighted option has a background tint (--color-primary-50).Arrow keys or mouse hover moves the highlight.
Option SelectedCheckmark icon or filled background on the selected option. Input shows the selected label.Value is committed.
Multi-Select ActiveSelected values appear as removable chips/tags inside the input. Input still allows typing.Each chip has a remove (✕) button.
LoadingSpinner 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.
DisabledMuted background, no interaction, cursor not-allowed.Not interactive.
ErrorRed border, error icon, error message below.Validation failed.
Read-onlyValue 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'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.
  • The dropdown has role="listbox".
  • Each option has role="option" with aria-selected="true|false".
  • For grouped options, use 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.
  • 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-activedescendant updates, 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="..."> or aria-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 Right and remove with Backspace or Delete.

Common Implementation Mistakes:

  1. Moving DOM focus into the listbox instead of using aria-activedescendant.
  2. Missing aria-expanded or not toggling it.
  3. Not announcing result counts to screen readers.
  4. Allowing mouse click on an option without updating aria-activedescendant first.
  5. Not closing the dropdown on Escape or Tab.

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

html
<!-- 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>
tsx
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.

Data InputAutocompleteType-aheadSearch Select