Skip to content

Combobox

A searchable single-select — a field-styled trigger that opens a Popover with a search box and a filtered, accent-insensitive listbox.

Stablev0.4.2added in v0.1.0@garn/ui/combobox
On this page

Default

Controlled single-select over a list of fruits.

With Label

A <Label htmlFor> wired to the trigger via id.

Sizes

xs · sm · md · lg · xl trigger heights.

Grouped

The data-driven `options` prop grouped by region.

Rich Options

Rich rows — avatar leading + a description line.

Async

Server-side search — filter=false, driven inputValue + loading, latest-wins.

Free Text

allowCustomValue + onCreate — a label input that creates new values.

Invalid

aria-invalid with a danger border + recovery message.

Disabled

A disabled trigger that won't open.

Installation

garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.

terminal
npx garn-ui add combobox
First time? Set up garn in your project
Registry
combobox
Deps
class-variance-authoritylucide-react
Registry deps
utilscmdkfield-variantsfield-surfaceuse-controllable-statepopoverspinner

Import

import { Combobox.Item, Combobox.Group, Combobox.Separator, Combobox.Empty } from "@garn/ui/combobox";

Anatomy

The parts this component is built from — compose them to assemble it.

Combobox.ItemRequired[data-slot="combobox-item"]

A selectable option row — leading + label (+ description) + a check when selected. Requires a stable value; human text rides in keywords for matching.

Combobox.Group[data-slot="combobox-group"]

A labelled section of options (cmdk CommandGroup with a heading).

Combobox.Separator[data-slot="combobox-separator"]

A divider between groups.

Combobox.Empty[data-slot="combobox-empty"]

The no-results state — cmdk auto-renders it when the filtered count is 0 (or pass emptyText with the options prop).

Props

The component's public props and their types.

appearance
Description
No description.
Type
"ghost" | "outline" | "soft"
Default
outline
size
Description
No description.
Type
"lg" | "md" | "sm" | "xl" | "xs"
Default
md
"aria-invalid"
Description
No description.
Type
"false" | "true" | boolean
"aria-label"
Description
No description.
Type
string
"aria-labelledby"
Description
No description.
Type
string
allowCustomValue
Description
Allow committing search text that matches no option (free-text).
Type
boolean
Default
false
children
Description
Compound `ComboboxItem` / `ComboboxGroup` children.
Type
React.ReactNode
className
Description
Applied to the trigger (the visible control surface).
Type
string
clearable
Description
Show a clear button on the trigger when a value is selected.
Type
boolean
Default
true
clearLabel
Description
Accessible name for the clear button.
Type
string
Default
"Clear"
contentClassName
Description
Applied to the popup content.
Type
string
createLabel
Description
Renders the "Create …" row label from the current search text.
Type
(inputValue: string) => React.ReactNode
defaultInputValue
Description
Initial search text (uncontrolled).
Type
string
Default
""
defaultOpen
Description
Whether the popover starts open (uncontrolled).
Type
boolean
Default
false
defaultValue
Description
Initial committed selection (uncontrolled).
Type
string
disabled
Description
Disable the trigger and block opening.
Type
boolean
Default
false
emptyText
Description
No-results content (used with the `options` prop; ignored if a `ComboboxEmpty` child is given).
Type
React.ReactNode
Default
"No results found."
filter
Description
Match function, or `false` to disable client filtering (feed pre-filtered `options` for server-side search). Defaults to an accent-insensitive fuzzy match.
Type
ComboboxFilter | false
id
Description
No description.
Type
string
inputValue
Description
In-popover search text (controlled) — drive this for server-side search.
Type
string
leading
Description
Trigger leading content; overrides the mirrored selected-option leading.
Type
React.ReactNode
loading
Description
Pending state: a spinner + a loading row + `aria-busy` + an announced "loading".
Type
boolean
Default
false
loadingText
Description
Text shown in the loading row and announced while `loading`.
Type
string
Default
"Loading…"
name
Description
Name for the hidden input mirroring `value` on form submit.
Type
string
onClear
Description
Fires after the selection is cleared.
Type
() => void
onCreate
Description
Called when a free-text value is committed; also renders a "Create …" row.
Type
(value: string) => void
onInputValueChange
Description
Fires when the search text changes.
Type
(inputValue: string) => void
onOpenChange
Description
Fires when the popover opens or closes.
Type
(open: boolean) => void
onValueChange
Description
Fires with the newly committed value (or `undefined` when cleared).
Type
(value: string | undefined) => void
open
Description
Popover open state (controlled).
Type
boolean
options
Description
Data-driven options — an alternative to compound children (one or the other).
Type
ComboboxOption[]
placeholder
Description
Trigger text when nothing is selected.
Type
string
Default
"Select…"
ref
Description
The trigger's ref.
Type
React.Ref<HTMLButtonElement>
required
Description
Mark the hidden form input required.
Type
boolean
searchLabel
Description
Accessible name for the search input (role=combobox).
Type
string
Default
"Search"
searchPlaceholder
Description
Search input placeholder (defaults to `placeholder`).
Type
string
value
Description
Committed selection (controlled).
Type
string

Plus 3 inherited native <button> attributes.

Combobox.Item

description
Description
Secondary line under the label.
Type
React.ReactNode
leading
Description
Leading glyph; mirrors into the trigger when this option is selected.
Type
React.ReactNode
textValue
Description
String used for trigger display + matching when `children` is a non-string node.
Type
string
valueRequired
Description
Stable value committed on select (required).
Type
string

Plus 283 inherited native HTML attributes.

Styling

Target these data-slots and states, and remap these tokens, to restyle without forking the component.

[data-slot="combobox-trigger"][data-slot="combobox"][data-slot="combobox-trigger-leading"][data-slot="combobox-trigger-label"][data-slot="combobox-clear"][data-slot="combobox-input"][data-slot="combobox-list"][data-slot="combobox-loading-row"][data-slot="combobox-create"]

States

SelectorState
:hoverhover
:focus-visiblefocus
[data-state=open]open
[data-placeholder]placeholder
[data-invalid]invalid
[data-slot=combobox-item][data-selected=true]item-selected
[aria-busy=true]loading
[disabled]disabled
PropertyTokenTier
triggerHeight--garn-control-h-mdsemantic · air
triggerPaddingInline--garn-control-px-mdsemantic · air
itemMinHeight--garn-control-h-mdsemantic · air

When to use

Pick one option from a long list where search helps — a typeahead/autocomplete field. Use the options prop or compound ComboboxItem children, drive inputValue for server-side search, and opt into allowCustomValue for free-text tags/labels.

Reach for something else when

  • A short list where seeing every option helps (use Select or RadioGroup)
  • an action palette / command menu (use Command)
  • a Select-based picker with no search (use BadgeSelect)
  • free-form text with no option list (use Input)
  • choosing many (this is single-select — a MultiCombobox is a fast-follow).

Overview

Combobox picks one option from a long list where typing to filter helps — a typeahead/autocomplete field (country, user, repo). Its field-styled trigger shares the appearance × size surface with Input and Select, opening a searchable listbox of Combobox.Items (group/separate/empty as needed). Drive inputValue for server-side search and opt into allowCustomValue for free-text tags. Use Select when the list is short and search adds nothing; use Command for an app-wide action palette.

Guidelines

Reach for Combobox when search is the point. If the list is short enough to scan, a plain Select is simpler; Combobox earns its weight on long or remote datasets. For server-side results, control inputValue and debounce the query rather than filtering everything client-side.

Give items stable values and searchable text. Each Item needs a stable value; put the human-readable words in its keywords so matching works even when the label is an id or is abbreviated. Always render an empty state so "no matches" is explicit.

Keep the field accessible and the custom-value path deliberate. Pair it with a Label, preserve the listbox keyboard model (type, arrow, enter), and only enable allowCustomValue when free text is genuinely valid (tags/labels) — otherwise restrict to the known set.

Best practices

Do
  • Label the trigger (aria-label / aria-labelledby / id + <label>) — the placeholder isn't a name.
  • Give every ComboboxItem a stable, unique value; put human text in the label (matching uses it via keywords).
  • For server-side search, set filter={false}, drive inputValue + loading, and feed pre-filtered options.
  • Use allowCustomValue + onCreate for tag/label inputs where new values are allowed.
  • Use the options prop for data, or compound children for rich/custom rows — one or the other, not both.
Don't
  • Don't reach for Combobox when 2–5 options would be clearer fully visible — that's Select / RadioGroup.
  • Don't treat the search text as the value — inputValue is the transient filter; value is what's committed.
  • Don't nest the clear button inside the trigger — it's a real sibling (the component already wires this).
  • Don't disable the result-count announcer or the focus ring — they're the a11y contract.

Content guidelines

  • Write a placeholder that names the choice ('Select a timezone'), and a distinct search placeholder ('Search timezones…').
  • Keep option labels short and parallel; group long lists with ComboboxGroup.

Troubleshooting

The selected value disappears or the field shows the search text.

Cause. Conflating value (committed) with inputValue (the transient search) — they're separate, controllable states.

Fix. Bind value + onValueChange for the selection; only touch inputValue for server-side search.

Server results never show, or show double-filtered.

Cause. The client fuzzy filter is still on top of your pre-filtered results.

Fix. Set filter={false}, drive inputValue + loading, and feed the already-filtered options.

Screen readers announce the field with no name.

Cause. The placeholder is not an accessible name (a dev console warns).

Fix. Pass aria-label / aria-labelledby, or an id paired with <label htmlFor>.

Typing a new value can't be committed.

Cause. Free-text is opt-in; without it, only existing options commit.

Fix. Set allowCustomValue (and onCreate to persist it) — a 'Create …' row then appears.

An option can't be found by typing its visible text.

Cause. cmdk matches on value (often an opaque id) + keywords, not the rendered node.

Fix. Pass textValue (or keywords) when the label is a non-string node so the human text stays searchable.

Accessibility

Role
combobox
ARIA APG
combobox
Focus
Trigger border darkens (foreground/40) + a subtle ring on focus-visible and while open; focus moves into the search on open and returns to the trigger on close (Radix).

Accessibility requirements

warn

Give the trigger an accessible name via aria-label, aria-labelledby, or an id + <label htmlFor>.

when the Combobox trigger has no `aria-label`, `aria-labelledby`, or an `id` paired with a <label htmlFor> (the placeholder doesn't count)

Keyboard

EnterSpaceOpen the listbox (focus moves into the search input).
A–ZType in the search to filter (accent-insensitive contains).
ArrowDownArrowUpMove the active option (tracked via aria-activedescendant; DOM focus stays in the input).
EnterCommit the active option (or the free-text value / 'Create' row) and close.
EscapeClose the popover; focus returns to the trigger.
A1.3.1Info and RelationshipsA2.1.1KeyboardAA2.4.7Focus VisibleA4.1.2Name, Role, Value
  • The trigger button carries aria-haspopup=listbox and reflects data-state; on open, focus moves into the cmdk search input which is the real role=combobox with aria-expanded / aria-controls / aria-activedescendant / aria-autocomplete.
  • A visually-hidden role=status aria-live region announces the result count / no-results / loading — the supplement VoiceOver/NVDA need because activedescendant counts are dropped.
  • Roving tabindex is deliberately NOT used (per the APG combobox pattern) — the active option is tracked via aria-activedescendant while DOM focus stays in the input.
  • The placeholder is NOT an accessible name — label the trigger via aria-label, aria-labelledby, or an `id` paired with <label htmlFor>.
  • The clear button is a real sibling button (never nested in the trigger) with its own accessible label.