Combo Box
Input-as-trigger single select with search, async options, and optional create-if-missing.
Pick one value from a list — or create it when it is missing. The field is an input (not a button): typing filters, opens the list, and can surface a Create row. Modes match Select: static, searchable static, async (onSearch), and preload. Use ComboBox for a label and helper text, ComboBoxControl for the field only (forms use it through Formily's FormItem), or useComboBox for fully custom layouts.
Built-in labels use useComboBoxI18n() under a tree with I18nProvider (registry item fui-i18n).
When To Use
- Need select-or-create for a single value (tags, vendors, freeform categories).
- Prefer an input-as-field so search and display share one control.
- Use Select instead when the value must come from a fixed list and a button trigger is enough.
- Use Multi-Select instead when the user picks many values (chips); Multi-Select already supports creatable for multi-value.
- Need numeric presets plus custom values (font size, quantity tiers) — pair number options with
inputModeand creatable. Prefer a dedicated Number Input when you need steppers, min/max, or continuous entry rather than pick-or-create.
Interactions
| Event | Behavior |
|---|---|
| Click anchor / chevron | Opens and stays open (browse; draft search cleared). |
Tab focus alone (menuTrigger='input') | Does not open; with a value, focus selects the label text so the next key replaces it. |
| Type in the field | Opens and filters (or debounced onSearch); never appends onto the closed label. |
| ArrowDown when closed | Opens the list. |
| Select option or Create | Commits and closes; focus stays on the input. |
| Escape / outside click / Tab away | Closes; keeps the current value; discards draft search. |
Empty input + Backspace with a value (clearable) | Clears the entire value. |
| Hover or focus with a selected value | Trailing suffix swaps from chevron to clear. |
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/combo-box.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/combo-box.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/combo-box.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/combo-box.jsonOr with a namespace: npx shadcn@latest add @f-ui/combo-box.
The CLI installs cmdk and lucide-react, and pulls command, label, and popover from the default shadcn registry, plus f-ui field-disabled-surface, field-control-shell, field-suffix, and fui-i18n.
Usage
import { ComboBox } from '@/components/f-ui/combo-box/combo-box';
import type { Option } from '@/components/f-ui/combo-box/combo-box-types';
const options: Option[] = [
{ value: 'bug', label: 'Bug' },
{ value: 'feature', label: 'Feature' },
];
<ComboBox
options={options}
value={value}
onValueChange={setValue}
creatable
/>Clearing emits null. With creatable and no onCreate, choosing Create calls onValueChange(input) — append to options in the parent if the new value should stay selectable. With onCreate, Create only invokes that callback; the parent appends options and sets the value (may be async).
Examples
Static Creatable
Small list with creatable. Type a new tag and choose Create — the demo appends it to options and selects it.
Pick an existing tag or create a new one
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { ComboBox } from "@/components/f-ui/combo-box/combo-box";
import type { Option } from "@/components/f-ui/combo-box/combo-box-types";
const TAGS: Option[] = [
{ value: "bug", label: "Bug" },
{ value: "feature", label: "Feature" },
{ value: "docs", label: "Docs" },
{ value: "chore", label: "Chore" },
];
export function ComboBoxDemo() {
const [options, setOptions] = useState<Option[]>(TAGS);
const [value, setValue] = useState<string | number | null>("feature");
return (
<div className="max-w-xs">
<ComboBox
label="Tag"
description="Pick an existing tag or create a new one"
options={options}
value={value}
creatable
onValueChange={(next) => {
setValue(next);
if (next == null) return;
setOptions((prev) => {
if (prev.some((o) => o.value === next)) return prev;
return [...prev, { value: next, label: String(next) }];
});
if (!options.some((o) => o.value === next)) {
toast.success(`Created ${next}`);
}
}}
/>
</div>
);
}Async + Creatable
onSearch runs per debounced keystroke; unmatched input still offers Create. Seed known labels with defaultOptions when restoring a selected id.
Debounced onSearch plus Create for unknown names
"use client";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { ComboBox } from "@/components/f-ui/combo-box/combo-box";
import type { Option } from "@/components/f-ui/combo-box/combo-box-types";
/** Static pool "fetched" asynchronously — replace with your API in real apps. */
const USERS: Option[] = [
{ value: "u1", label: "Ada Lovelace" },
{ value: "u2", label: "Alan Turing" },
{ value: "u3", label: "Grace Hopper" },
{ value: "u4", label: "Katherine Johnson" },
{ value: "u5", label: "Margaret Hamilton" },
{ value: "u6", label: "Donald Knuth" },
{ value: "u7", label: "Barbara Liskov" },
{ value: "u8", label: "Edsger Dijkstra" },
];
function delay(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
export function ComboBoxAsyncDemo() {
const [extra, setExtra] = useState<Option[]>([]);
const [value, setValue] = useState<string | number | null>(null);
const onSearch = useCallback(
async (query: string) => {
await delay(400);
const q = query.trim().toLowerCase();
const pool = [...USERS, ...extra];
if (!q) return pool.slice(0, 5);
return pool.filter((u) => u.label.toLowerCase().includes(q));
},
[extra],
);
return (
<div className="max-w-xs">
<ComboBox
label="Assignee"
description="Debounced onSearch plus Create for unknown names"
onSearch={onSearch}
triggerSearchOnFocus
creatable
value={value}
onValueChange={(next) => {
setValue(next);
if (next == null) return;
const known =
extra.some((o) => o.value === next) ||
USERS.some((o) => o.value === next);
if (known) return;
setExtra((prev) =>
prev.some((o) => o.value === next)
? prev
: [...prev, { value: next, label: String(next) }],
);
toast.success(`Created ${next}`);
}}
placeholder="Search or create"
/>
</div>
);
}onCreate Appends Then Sets Value
onCreate owns persistence: the demo waits briefly, appends the option, then sets value. While pending, the control stays busy via aria-busy.
onCreate appends the option, then sets the value
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { ComboBox } from "@/components/f-ui/combo-box/combo-box";
import type { Option } from "@/components/f-ui/combo-box/combo-box-types";
const INITIAL: Option[] = [
{ value: "acme", label: "Acme Corp" },
{ value: "globex", label: "Globex" },
{ value: "initech", label: "Initech" },
];
function delay(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
export function ComboBoxOnCreateDemo() {
const [options, setOptions] = useState<Option[]>(INITIAL);
const [value, setValue] = useState<string | number | null>(null);
const [creating, setCreating] = useState(false);
return (
<div className="max-w-xs">
<ComboBox
label="Vendor"
description="onCreate appends the option, then sets the value"
options={options}
value={value}
onValueChange={setValue}
creatable
aria-busy={creating || undefined}
onCreate={async (input) => {
setCreating(true);
try {
await delay(500);
const option: Option = { value: input, label: input };
setOptions((prev) =>
prev.some((o) => o.value === option.value)
? prev
: [...prev, option],
);
setValue(option.value);
toast.success(`Created ${option.label}`);
} finally {
setCreating(false);
}
}}
/>
</div>
);
}Numeric Values
This is not a Number Input — it is still pick-or-create. Use inputMode="decimal" (or inputProps) for a numeric keyboard; preset options may use number values, but Create without onCreate still emits a string. Parse in onCreate (or the host) if you need a number.
Presets are numbers; Create commits a string unless you parse in onCreate
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { ComboBox } from "@/components/f-ui/combo-box/combo-box";
import type { Option } from "@/components/f-ui/combo-box/combo-box-types";
const PRESETS: Option[] = [
{ value: 12, label: "12" },
{ value: 14, label: "14" },
{ value: 16, label: "16" },
{ value: 18, label: "18" },
];
export function ComboBoxNumericDemo() {
const [options, setOptions] = useState<Option[]>(PRESETS);
const [value, setValue] = useState<string | number | null>(14);
return (
<div className="max-w-xs">
<ComboBox
label="Font size"
description="Presets are numbers; Create commits a string unless you parse in onCreate"
options={options}
value={value}
inputMode="decimal"
creatable
onValueChange={(next) => {
setValue(next);
if (next == null) return;
const exists = options.some((o) => o.value === next);
if (exists) return;
setOptions((prev) =>
prev.some((o) => o.value === next)
? prev
: [...prev, { value: next, label: String(next) }],
);
toast.success(`Created ${next}`);
}}
/>
</div>
);
}Headless Usage
useComboBox is the view-model: open state, filtering / async search, creatable row, and selection handlers. The demo uses a native <input role="combobox"> — not ComboBoxControl. Wire ComboBoxDropdown for the list surface inside the same Command as the input (Multi-Select pattern).
value: p2
"use client";
import { useId, useState } from "react";
import { ComboBoxDropdown } from "@/components/f-ui/combo-box/combo-box-parts/combo-box-dropdown";
import type { Option } from "@/components/f-ui/combo-box/combo-box-types";
import { useComboBox } from "@/components/f-ui/combo-box/use-combo-box";
import { Command } from "@/components/ui/command";
import { Popover, PopoverAnchor } from "@/components/ui/popover";
const PRIORITIES: Option[] = [
{ value: "p0", label: "Urgent" },
{ value: "p1", label: "High" },
{ value: "p2", label: "Normal" },
{ value: "p3", label: "Low" },
];
export function ComboBoxHeadlessDemo() {
const [options, setOptions] = useState<Option[]>(PRIORITIES);
const [value, setValue] = useState<string | number | null>("p2");
const listId = useId();
const s = useComboBox({
value,
onValueChange: (next) => {
setValue(next);
if (next == null) return;
setOptions((prev) =>
prev.some((o) => o.value === next)
? prev
: [...prev, { value: next, label: String(next) }],
);
},
options,
creatable: true,
});
const selected = s.selectedOption;
const hasValue = value != null && value !== "";
return (
<div>
<Popover modal={false} open={s.open} onOpenChange={s.onOpenChange}>
<Command shouldFilter={s.shouldFilter}>
<div>
<label htmlFor="headless-combo-box">Priority</label>
<PopoverAnchor asChild>
<div>
<input
id="headless-combo-box"
ref={s.anchorRef}
role="combobox"
aria-expanded={s.open}
aria-controls={listId}
aria-autocomplete="list"
aria-label="Priority"
value={s.inputDisplayValue}
placeholder={s.placeholder}
onChange={(e) => s.onInputValueChange(e.target.value)}
onKeyDown={s.onAnchorKeyDown}
onFocus={s.onAnchorFocus}
onClick={s.onPointerOpen}
/>
{s.clearVisible ? (
<button type="button" onClick={s.onClear}>
{s.t("comboBox.clear")}
</button>
) : null}
</div>
</PopoverAnchor>
<ComboBoxDropdown
options={s.displayedOptions}
selectedValue={hasValue ? value : null}
onSelect={s.onSelect}
isLoading={s.isLoading}
loadingLabel={s.t("comboBox.loading")}
errorMessage={s.errorMessage}
noResultsLabel={s.t("comboBox.noResults")}
creatable={s.creatable}
listId={listId}
/>
</div>
</Command>
</Popover>
<p>value: {selected?.label ?? value ?? "—"}</p>
</div>
);
}Composition
ComboBox (optional label + description shell)
└── ComboBoxControl
├── Popover
└── Command
├── PopoverAnchor → ComboBoxAnchor (input + clear / chevron)
└── ComboBoxDropdown → PopoverContent
└── CommandList
├── CommandEmpty | loading / error row
├── CommandGroup → CommandItem (check on selected)
└── Create row (creatable, no exact match)API Reference
Props
| Prop | Type | Default |
|---|---|---|
value | string | number | null | — |
onValueChange | (value: string | number | null) => void | — |
options | Option[] | — (Option.value is string | number) |
defaultOptions | Option[] | — (seeds the label cache) |
onSearch | (query: string) => Promise<Option[]> | — |
preload | boolean | false |
filterFn | (option: Option, query: string) => boolean | label/value includes |
creatable | boolean | false |
onCreate | (input: string) => void | Promise<void> | — (takes over Create; no auto onValueChange) |
searchable | boolean | inferred (see modes) |
clearable | boolean | true |
delay | number | 300 |
triggerSearchOnFocus | boolean | false |
menuTrigger | 'input' | 'focus' | 'manual' | 'input' |
placeholder | string | comboBox.placeholder |
renderOption / renderValue | (option: Option) => ReactNode | option.label |
loadingIndicator / emptyIndicator | ReactNode | built-in states |
surface | 'default' | 'compact' | 'tableCell' | 'default' |
label / description | ReactNode | — (ComboBox shell only) |
inputMode | HTML inputMode | — (forwarded to the textbox) |
inputProps | { autoComplete?, name?, enterKeyHint? } | — (narrow pass-through) |
locale / t | i18n overrides | provider context |
Slots
| Slot | Applied to |
|---|---|
root | Wrapper around label + control + description |
label | Label |
control / anchor | Anchor field shell |
content | PopoverContent |
list | CommandList |
item | Each CommandItem |
Hook
| Function | useComboBox(props: UseComboBoxOptions) |
| Options | UseComboBoxOptions — same shape as ComboBoxFieldProps. |
| Return | UseComboBoxReturn: mode, open / onOpenChange, anchorRef, inputDisplayValue, displayedOptions, creatable, selectedOption, isLoading, isCreating, errorMessage, onSelect, onClear, onCreatableSelect, t, etc. |