apx-dsv0.1Local renderer · live
K
Foundations4
  • Getting started
  • Theming
  • Templates
  • Icons
  • Avatar
  • Badge
  • DataGrid
  • Scheduler
  • Stat
  • Table
  • Timeline
  • TreeView
  • Accordion
  • Alert
  • EmptyState
  • Progress
  • Skeleton
  • Spinner
  • SplashScreen
  • Toast
  • ColorPicker
  • FileUpload
  • Form
  • Rating
  • TagsInput
  • Combobox
  • Field
  • Select
  • Toggle
  • Button
  • Calendar
  • Checkbox
  • DatePicker
  • Input
  • NumberInput
  • Radio
  • Slider
  • Switch
  • Textarea
  • AppShell
  • Div
  • Divider
  • Sidebar
  • Stack
  • Typography
  • Image
  • Breadcrumbs
  • Carousel
  • NavigationMenu
  • Pagination
  • Stepper
  • Tabs
  • Toolbar
  • CommandPalette
  • Confirm
  • Drawer
  • HoverCard
  • Menu
  • Modal
  • Popover
  • Tooltip
  • Icon
  • Card
  • PricingCard
60 componentsapx-ds/renderer
Combobox
Variant↳ other

Forms

Combobox

Searchable-select primitive —

Overview

<Combobox /> is the canonical searchable-select primitive — a <Select /> with a typeable input that filters its option list as the user types. <MultiCombobox /> is the same component with multi-select semantics: selected values render as removable <Badge> chips before the input, Backspace on an empty query removes the last chip.

Overview — searchable select with filtered options

Loading preview…
Overview.tsx

Combobox sits at the intersection of three component families:

  • Form-control — same controlBase + variantColorMatrix shell as <Input /> / <Textarea /> / <Select />. Combobox is the fourth consumer of the shared matrix.
  • Overlay — reuses Select's listbox surface, item recipe, motion, and usePosition (matchTriggerWidth middleware), so the dropdown reads as a sibling of Select / Menu / Popover.
  • List keyboard — reuses _shared/useListKeyboard.ts, validated for a third time (Menu → Select → Combobox). The hook's filter-driven getItems() accessor was designed precisely for this case where the visible item set changes on every keystroke.

When to use

  • A picker bound to a long list (countries, tags, users) where typing-to-narrow saves clicks.
  • A multi-select tag input — pass creatable + onCreateOption for the canonical tag UX.
  • An autocomplete bound to a remote API — pass loadOptions with debouncing handled by the built-in useDeferredFilter.

When NOT to use

  • A short fixed list (< 8 options) with no search benefit → use <Select />.
  • A free-form text input with hints (no commitment to a value) → use <Input /> with a separate suggestion popover.
  • A command launcher (actions, not values) → use the future <CommandPalette />.

Anatomy

text
<Combobox|MultiCombobox
  options=[…]                  // static
  loadOptions={async (q) => …} // OR async — overrides static when both passed
  value=…                      // string | null   (single)
                               // string[]        (multi)
  onChange=…
  inputValue=…
  onInputValueChange=…
  placeholder=…
  variant size color           // same vocab as Input / Select
  matchStrategy="substring|startsWith|fuzzy|custom"
  filterOption=(opt, query) => boolean   // wins over matchStrategy
  creatable + onCreateOption
  clearable closeOnSelect openOnFocus
  debounceMs loadingState
  open defaultOpen onOpenChange placement matchTriggerWidth
  renderOption renderEmpty renderLoading renderError renderCreateOption
  disabled invalid required name
  portalContainer translations
  id aria-label aria-labelledby aria-describedby
  className style sx
  inputProps                    // pass-through to the underlying <input>
/>
<Combobox|MultiCombobox
  options=[…]                  // static
  loadOptions={async (q) => …} // OR async — overrides static when both passed
  value=…                      // string | null   (single)
                               // string[]        (multi)
  onChange=…
  inputValue=…
  onInputValueChange=…
  placeholder=…
  variant size color           // same vocab as Input / Select
  matchStrategy="substring|startsWith|fuzzy|custom"
  filterOption=(opt, query) => boolean   // wins over matchStrategy
  creatable + onCreateOption
  clearable closeOnSelect openOnFocus
  debounceMs loadingState
  open defaultOpen onOpenChange placement matchTriggerWidth
  renderOption renderEmpty renderLoading renderError renderCreateOption
  disabled invalid required name
  portalContainer translations
  id aria-label aria-labelledby aria-describedby
  className style sx
  inputProps                    // pass-through to the underlying <input>
/>
  • Single root element — Combobox is not a compound component; it uses a props-driven API (options, renderOption, filterOption, renderEmpty, …).
  • Two exports for type safety — <Combobox> pins value: string | null; <MultiCombobox> pins value: string[]. Both render the same internal implementation; the only behavior delta is "close on select?" and "render tag chips?".

Variants

The input shell reuses Input / Textarea / Select's 4 × 7 form-control matrix verbatim — adding a color or variant happens once in _shared/variantColorMatrix.ts and all four surfaces light up.

VariantChrome
outline1px border + paper background. Default.
solidbg-subtle resting; pops to paper on focus.
ghostBorderless at rest; gains border + tint on hover/focus.
underlineBottom rule only; minimal chrome.

Sizes

SizeMin-heightPadding-XFont
smmin-h-8px-2 py-1text-sm
mdmin-h-10px-2.5 py-1text-sm
lgmin-h-12px-3 py-1.5text-base

min-h (not h) because multi-mode tags wrap onto multiple lines; the shell grows with content to keep every chip visible.

Filter strategies

StrategyPredicate
substringCase-insensitive .includes(). Default.
startsWithCase-insensitive .startsWith().
fuzzySimple subsequence match (every query char appears in label in order).
customDefers entirely to filterOption. Use when neither shortcut fits.

Want ranked fuzzy (Fuse.js / fzf-style)? Bring your own ranker via filterOption — the bundled fuzzy is intentionally yes-or-no with no scoring, because shipping a scorer would force a ranking opinion that's almost always wrong for some consumer.

Async loading

tsx
<Combobox
  loadOptions={async (query, { signal }) => {
    const res = await fetch(`/api/users?q=${encodeURIComponent(query)}`, { signal });
    if (!res.ok) throw new Error('Failed to load users');
    return (await res.json()).map((u: User) => ({ value: u.id, label: u.name }));
  }}
  debounceMs={300}
/>
<Combobox
  loadOptions={async (query, { signal }) => {
    const res = await fetch(`/api/users?q=${encodeURIComponent(query)}`, { signal });
    if (!res.ok) throw new Error('Failed to load users');
    return (await res.json()).map((u: User) => ({ value: u.id, label: u.name }));
  }}
  debounceMs={300}
/>

The bundled useDeferredFilter hook:

  1. Debounces query changes by debounceMs (default 300).
  2. Aborts the previous fetch via AbortController so stale results never overwrite fresh ones.
  3. Switches loadingState between 'idle' | 'loading' | 'ready' | 'empty' | 'error'.
  4. Cancels the in-flight controller on unmount so the promise resolves harmlessly.

The hook is exported publicly (useDeferredFilter) so future async-list components (CommandPalette, search dropdowns) can reuse the exact lifecycle.

Creatable mode

tsx
<MultiCombobox
  options={existingTags}
  value={selectedTags}
  onChange={setSelectedTags}
  creatable
  onCreateOption={(label) => ({ value: slugify(label), label })}
/>
<MultiCombobox
  options={existingTags}
  value={selectedTags}
  onChange={setSelectedTags}
  creatable
  onCreateOption={(label) => ({ value: slugify(label), label })}
/>

When creatable is on and the current query doesn't exactly match an existing option, a + Create "{query}" row appears at the tail of the list. Activating it (Enter / click) calls onCreateOption(query) and adds the resulting option to the selection. Supports both sync and Promise<Option> returns.

Form integration

Pass name and the component emits hidden <input type="hidden"> siblings — one for single mode, one per selected value for multi mode (so form serialization yields name=a&name=b&name=c exactly as a native <select multiple> would).

tsx
<form>
  <Combobox name="country" required placeholder="Pick a country" options={…} />
  <MultiCombobox name="skills" placeholder="Pick skills" options={…} />
</form>
<form>
  <Combobox name="country" required placeholder="Pick a country" options={…} />
  <MultiCombobox name="skills" placeholder="Pick skills" options={…} />
</form>

Accessibility

W3C ARIA Combobox + Listbox pattern (APG 1.2):

  • Input — role="combobox" + aria-haspopup="listbox" + aria-expanded + aria-controls (only when open) + aria-autocomplete="list" + aria-activedescendant pointing to the currently-highlighted option id. aria-multiselectable is set on the listbox in multi mode.
  • Listbox — role="listbox" + aria-labelledby the trigger.
  • Options — role="option" + aria-selected + aria-disabled for disabled items + data-highlighted="true" for the keyboard-highlighted row.
  • Tags (multi) — rendered via <Badge removable>; each remove button gets aria-label={t.removeTag(label)}.
  • useFormFieldA11y bridges id / aria-invalid / aria-required / aria-describedby from the root onto the input — same hook Input / Textarea / Select use.

Keyboard

KeyAction
Printable characterUpdate query + filter list (the input is the search field).
ArrowDown (closed)Open + highlight first enabled item.
ArrowDown / ArrowUp (open)Cycle highlight (wraps).
Home / EndFirst / last enabled item.
Enter / Space (open)Select highlighted item. If creatable + no exact match → call onCreateOption(query).
EscClose without selecting.
Tab / Shift+TabClose + advance focus.
Backspace (multi, empty)Remove the last selected tag.

Type-ahead from useListKeyboard is intentionally disabled on Combobox — the input itself is the search affordance, so intercepting printable keys would prevent the query from updating. Filtering is the type-ahead, only better.

axe-core: 0 violations across single + multi, all four variants, and the disabled / invalid / required states.

Examples

Basic

Loading preview…
Basic.tsx

Grouped

Loading preview…
Grouped.tsx

Async

Loading preview…
Async.tsx

Multi

Loading preview…
Multi.tsx

MultiCreatable

Loading preview…
MultiCreatable.tsx

CustomItem

Loading preview…
CustomItem.tsx

HighlightMatches

Loading preview…
HighlightMatches.tsx

FuzzyMatch

Loading preview…
FuzzyMatch.tsx

Variants

Loading preview…
Variants.tsx

Sizes

Loading preview…
Sizes.tsx

Disabled

Loading preview…
Disabled.tsx

Invalid

Loading preview…
Invalid.tsx

FormSubmission

Loading preview…
FormSubmission.tsx

Controlled

Loading preview…
Controlled.tsx

Theming

tsx
defineTheme({
  components: {
    Combobox: {
      defaultProps: { matchStrategy: 'fuzzy', clearable: false },
      styleOverrides: {
        wrapper: 'shadow-sm',
        input: '',
        content: 'shadow-xl',
        item: 'rounded-md',
        groupLabel: 'text-fg',
        empty: '',
        loading: '',
        error: '',
        createRow: 'text-primary',
        clearButton: '',
      },
    },
  },
});
defineTheme({
  components: {
    Combobox: {
      defaultProps: { matchStrategy: 'fuzzy', clearable: false },
      styleOverrides: {
        wrapper: 'shadow-sm',
        input: '',
        content: 'shadow-xl',
        item: 'rounded-md',
        groupLabel: 'text-fg',
        empty: '',
        loading: '',
        error: '',
        createRow: 'text-primary',
        clearButton: '',
      },
    },
  },
});

Per-instance overrides via <Combobox className sx style /> merge on top of theme overrides, which merge on top of the recipe — same precedence chain Input / Select use.

Headless layer

Three pure helpers + one hook are exported alongside the components:

ExportPurpose
flattenOptions(options)Flatten nested (Option | Group)[] to parallel options + groupLabels.
filterStrategies.substring | .startsWith | .fuzzyDrop-in (label, query) => boolean predicates.
fuzzyMatch(label, query)The underlying yes/no subsequence-match function.
highlightMatch(label, query, mark?)Returns ReactNode with <mark>-wrapped matches.
useDeferredFilter({ loadOptions, query, debounceMs, enabled })Async lifecycle hook.

These are deliberately framework-light (no DOM, no React-only dependencies beyond useDeferredFilter) so they can be reused outside Combobox.

Props

PropTypeDefaultDescription
aria-describedbystring—`aria-describedby` for the input.
aria-labelstring—`aria-label` for the input.
aria-labelledbystring—`aria-labelledby` for the input.
classNamestring—Forwarded to the wrapper.
clearableboolean—Render a clear-all "×" button on the right edge of the input. Default: `true`.
closeOnSelectboolean—Single mode: close on select. Multi: stay open. Override per prop.
colorResponsiveValue<ComboboxColor>—Palette role. Default: `'primary'`.
creatableboolean—Allow creating a new option from the current query.
debounceMsnumber—Async fetch debounce in ms. Default: `300`.
defaultInputValuestring—Uncontrolled initial query. Default: `''`.
defaultOpenboolean—Uncontrolled initial open. Default: `false`.
defaultValuestring | null—Uncontrolled initial value. Default: `null`.
disabledboolean—Disable interaction.
filterOption(option: ComboboxOption, query: string) => boolean—Per-option predicate. Wins over `matchStrategy` when provided.
fullWidthResponsiveValue<boolean>—Stretch the input shell to fill its container. Default: `true`.
idstring—Explicit id for the input element.
inputPropsOmit<InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "role" | "aria-activedescendant">—Native `<input>` props that pass through (e.g. `autoFocus`, `readOnly`, `inputMode`). We explicitly **keep** `onChange` / `onFocus` / `onKeyDown` callable here so consumers can observe + decorate them; Combobox composes its own handlers on top and only short-circuits when the consumer calls `event.preventDefault()`.
inputValuestring—Controlled query (input value).
invalidboolean—`aria-invalid` + danger border / ring.
loadingStateenum—Explicit loading override. When set, takes precedence over the internal `useDeferredFilter` state — useful when the consumer is driving the fetch lifecycle themselves.
loadOptions(query: string, ctx: { signal: AbortSignal; }) => Promise<ComboboxOption[]>—Async option loader. When provided, replaces the static list; debounced + AbortController-aware via the bundled `useDeferredFilter`. Resolves with the option list to display for `query`.
matchStrategyenum—Built-in filter shortcut. `'custom'` defers to `filterOption`. Default: `'substring'`.
matchTriggerWidthboolean—Sync the listbox width to the input's width. Default: `true`.
namestring—Form name. When set, a hidden `<input type="hidden" name value>` participates in form submission.
onChange(value: string | null) => void—Fires when the selection changes.
onCreateOption(label: string) => ComboboxOption | Promise<ComboboxOption>—Called when the user activates "Create '{query}'" via Enter or click. Should return the new option (sync or async). The returned option's `value` is then added to the selection.
onInputValueChange(value: string) => void—Fires whenever the query changes.
onOpenChange(open: boolean) => void—Fires when the listbox opens / closes.
openboolean—Controlled open state.
openOnFocusboolean—Open the listbox the moment the input gains focus. Default: `false`.
optionsComboboxOptionOrGroup<ComboboxOption>[]—Static option list. Ignored when `loadOptions` is provided.
placeholderstring—Placeholder for the input. Default: `'Search…'`.
placementenum—Preferred placement of the listbox. Default: `'bottom-start'`.
portalContainerHTMLElement | null—Override the portal container for the listbox.
renderCreateOption(label: string) => ReactNode—"Create '{label}'" row renderer. Default: a translated label inside an option-styled row.
renderEmpty(query: string) => ReactNode—Empty-state body (no results for the current query). Receives the current query.
renderError(error: Error) => ReactNode—Error-state body. Receives the rejected error.
renderLoading() => ReactNode—Loading-state body.
renderOption(ctx: ComboboxRenderOptionContext<ComboboxOption>) => ReactNode—Custom item renderer. Receives `{ option, isActive, isSelected, query }`.
requiredboolean—Mirrors native `required`.
sizeResponsiveValue<ComboboxSize>—Size axis. Matches Input height per size. Default: `'md'`.
styleCSSProperties——
sxSx——
translationsPartial<ComboboxTranslations>—Translation overrides. Defaults to bundled English.
valuestring | null—Controlled value.
variantResponsiveValue<ComboboxVariant>—Visual chrome for the input shell. Default: `'outline'`.