f-ui
Components

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 inputMode and creatable. Prefer a dedicated Number Input when you need steppers, min/max, or continuous entry rather than pick-or-create.

Interactions

EventBehavior
Click anchor / chevronOpens 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 fieldOpens and filters (or debounced onSearch); never appends onto the closed label.
ArrowDown when closedOpens the list.
Select option or CreateCommits and closes; focus stays on the input.
Escape / outside click / Tab awayCloses; keeps the current value; discards draft search.
Empty input + Backspace with a value (clearable)Clears the entire value.
Hover or focus with a selected valueTrailing suffix swaps from chevron to clear.

Installing

pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/combo-box.json
npx shadcn@latest add https://ui.isaacfei.com/r/combo-box.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/r/combo-box.json
bun x shadcn@latest add https://ui.isaacfei.com/r/combo-box.json

Or 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

PropTypeDefault
valuestring | number | null
onValueChange(value: string | number | null) => void
optionsOption[]— (Option.value is string | number)
defaultOptionsOption[]— (seeds the label cache)
onSearch(query: string) => Promise<Option[]>
preloadbooleanfalse
filterFn(option: Option, query: string) => booleanlabel/value includes
creatablebooleanfalse
onCreate(input: string) => void | Promise<void>— (takes over Create; no auto onValueChange)
searchablebooleaninferred (see modes)
clearablebooleantrue
delaynumber300
triggerSearchOnFocusbooleanfalse
menuTrigger'input' | 'focus' | 'manual''input'
placeholderstringcomboBox.placeholder
renderOption / renderValue(option: Option) => ReactNodeoption.label
loadingIndicator / emptyIndicatorReactNodebuilt-in states
surface'default' | 'compact' | 'tableCell''default'
label / descriptionReactNode— (ComboBox shell only)
inputModeHTML inputMode— (forwarded to the textbox)
inputProps{ autoComplete?, name?, enterKeyHint? }— (narrow pass-through)
locale / ti18n overridesprovider context

Slots

SlotApplied to
rootWrapper around label + control + description
labelLabel
control / anchorAnchor field shell
contentPopoverContent
listCommandList
itemEach CommandItem

Hook

FunctionuseComboBox(props: UseComboBoxOptions)
OptionsUseComboBoxOptions — same shape as ComboBoxFieldProps.
ReturnUseComboBoxReturn: mode, open / onOpenChange, anchorRef, inputDisplayValue, displayedOptions, creatable, selectedOption, isLoading, isCreating, errorMessage, onSelect, onClear, onCreatableSelect, t, etc.

On this page