Combobox
A searchable single-select — a field-styled trigger that opens a Popover with a cmdk search box and a filtered listbox. Accent-insensitive matching, grouped + rich options, a data-driven options prop, async/loading, opt-in free-text create, a clear control, and an aria-live result-count announcer.
@garn/ui/comboboxDefault
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.
Please select a country.
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- Source
- combobox.tsx
- Deps
class-variance-authoritylucide-react- Registry deps
utilscmdkfield-variantsfield-surfaceuse-controllable-statepopoverspinner
Import
import { Combobox } from "@garn/ui/combobox";Props
Generated from the component's TypeScript 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
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. Default true.
- Type
boolean
clearLabel- Description
- No description.
- Type
string
contentClassName- Description
- Applied to the popup content.
- Type
string
createLabel- Description
- No description.
- Type
(inputValue: string) => React.ReactNode
defaultInputValue- Description
- No description.
- Type
string
defaultOpen- Description
- No description.
- Type
boolean
defaultValue- Description
- No description.
- Type
string
disabled- Description
- No description.
- Type
boolean
emptyText- Description
- No-results content (used with the `options` prop; ignored if a `ComboboxEmpty` child is given).
- Type
React.ReactNode
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
loadingText- Description
- No description.
- Type
string
name- Description
- Name for the hidden input mirroring `value` on form submit.
- Type
string
onClear- Description
- No description.
- Type
() => void
onCreate- Description
- Called when a free-text value is committed; also renders a "Create …" row.
- Type
(value: string) => void
onInputValueChange- Description
- No description.
- Type
(inputValue: string) => void
onOpenChange- Description
- No description.
- Type
(open: boolean) => void
onValueChange- Description
- No description.
- 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
ref- Description
- The trigger's ref.
- Type
React.Ref<HTMLButtonElement>
required- Description
- No description.
- Type
boolean
searchLabel- Description
- Accessible name for the search input (role=combobox).
- Type
string
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 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"]| Property | Token | Tier |
|---|---|---|
triggerHeight | --garn-control-h-md | semantic · air |
triggerPaddingInline | --garn-control-px-md | semantic · air |
itemMinHeight | --garn-control-h-md | semantic · air |
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.
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).
Enforceable contracts
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.
- Status
- Stable
- Version
v0.3.0- Introduced
v0.1.0
garn doesn't yet record a per-release version history. This reflects the component's current lifecycle state; full changelog entries will appear once the metadata pipeline emits a lifecycle.history field.