Combobox
A searchable single-select — a field-styled trigger that opens a Popover with a search box and a filtered, accent-insensitive listbox.
@garn/ui/comboboxOn 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.
npx garn-ui add combobox- 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
| Selector | State | Description |
|---|---|---|
:hover | hover | The trigger edge / fill grows stronger (shared field surface). |
:focus-visible | focus | The trigger border grows less transparent (foreground/40) and a subtle ring appears; soft/ghost clear their fill. |
[data-state=open] | open | The popover is open (typing in the search) — the trigger keeps the focus/ring treatment. |
[data-placeholder] | placeholder | Nothing is selected — the trigger label reads muted. |
[data-invalid] | invalid | aria-invalid is set — the trigger border + ring turn danger. |
[data-slot=combobox-item][data-selected=true] | item-selected | The active/highlighted row (cmdk); the chosen option shows its trailing check. |
[aria-busy=true] | loading | Async pending — a spinner replaces the chevron, a loading row shows, and the count is announced as loading. |
[disabled] | disabled | The trigger dims and won't open. |
| Property | Token | Tier |
|---|---|---|
triggerHeight | --garn-control-h-md | semantic · air |
triggerPaddingInline | --garn-control-px-md | semantic · air |
itemMinHeight | --garn-control-h-md | semantic · 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
- 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}, driveinputValue+loading, and feed pre-filteredoptions. - Use
allowCustomValue+onCreatefor tag/label inputs where new values are allowed. - Use the
optionsprop for data, or compound children for rich/custom rows — one or the other, not both.
- 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 —
inputValueis the transient filter;valueis 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
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
| EnterSpace | Open the listbox (focus moves into the search input). |
| A–Z | Type in the search to filter (accent-insensitive contains). |
| ArrowDownArrowUp | Move the active option (tracked via aria-activedescendant; DOM focus stays in the input). |
| Enter | Commit the active option (or the free-text value / 'Create' row) and close. |
| Escape | Close the popover; focus returns to the trigger. |
- 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.