Combobox

Searchable single-select input. Composes Popover + Command (cmdk) + a styled trigger. Pass a list of { value, label } options.

Default

Pre-selected

Disabled

Installation

pnpm
pnpm dlx @hex-core/cli add combobox

Filtered subset

Drive the option list from a parent filter — the Combobox itself only handles selection, not search-time fetching. For true server-side async search, drop down to <Command> + <Popover> primitives where <CommandInput onValueChange> is exposed.

tsx
import { useMemo, useState } from "react";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";

type Team = "all" | "backend" | "frontend" | "design";

const ALL: ComboboxOption[] = [
  { value: "alice", label: "Alice (Backend)" },
  { value: "bob", label: "Bob (Frontend)" },
  { value: "carla", label: "Carla (Design)" },
  { value: "diego", label: "Diego (Backend)" },
  { value: "eun-jin", label: "Eun-jin (Frontend)" },
  { value: "felix", label: "Felix (Design)" },
];

export function FilteredCombobox() {
  // In your app, drive `team` from any UI — a RadioGroup, Tabs, Select,
  // a URL search param, etc. The point is that the option list is derived,
  // not fetched in response to typing inside the Combobox.
  const [team, setTeam] = useState<Team>("all");
  const [pick, setPick] = useState<string>();

  const options = useMemo(
    () =>
      team === "all"
        ? ALL
        : ALL.filter((o) => o.label.toLowerCase().includes(team)),
    [team],
  );

  return (
    <Combobox
      aria-label="Assignee"
      options={options}
      value={pick}
      onChange={setPick}
      placeholder="Pick assignee…"
    />
  );
}

Custom item render via Command + Popover

When you need richer per-row content (avatar, secondary line, badge), drop down from the Combobox wrapper to its Command + Popover primitives. This keeps full control of the row markup while inheriting the same keyboard nav and filtering.

tsx
import { useState } from "react";
import { Check } from "lucide-react";
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";

const assignees = [
  { id: "u_olivia", name: "Olivia Park", role: "Engineering Lead", avatar: "/people/olivia.png" },
  { id: "u_marcus", name: "Marcus Webb", role: "Design", avatar: "/people/marcus.png" },
  { id: "u_priya", name: "Priya Shah", role: "Product", avatar: "/people/priya.png" },
];

export function AssigneePicker() {
  const [open, setOpen] = useState(false);
  const [selectedId, setSelectedId] = useState<string>();
  const selected = assignees.find((a) => a.id === selectedId);

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger className="flex h-10 w-72 items-center gap-2 rounded-md border border-input bg-background px-3 text-sm shadow-sm hover:bg-accent">
        {selected ? (
          <>
            <Avatar className="h-6 w-6">
              <AvatarImage src={selected.avatar} alt="" />
              <AvatarFallback>{selected.name.slice(0, 1)}</AvatarFallback>
            </Avatar>
            <span className="truncate">{selected.name}</span>
          </>
        ) : (
          <span className="text-muted-foreground">Assign teammate…</span>
        )}
      </PopoverTrigger>
      <PopoverContent align="start" className="w-72 p-0">
        <Command>
          <CommandInput placeholder="Search teammates…" />
          <CommandList>
            <CommandEmpty>No teammates found.</CommandEmpty>
            <CommandGroup>
              {assignees.map((person) => (
                <CommandItem
                  key={person.id}
                  value={person.name}
                  onSelect={() => {
                    setSelectedId(person.id);
                    setOpen(false);
                  }}
                  className="flex items-center gap-3"
                >
                  <Avatar className="h-7 w-7">
                    <AvatarImage src={person.avatar} alt="" />
                    <AvatarFallback>{person.name.slice(0, 1)}</AvatarFallback>
                  </Avatar>
                  <div className="grid leading-tight">
                    <span className="text-sm font-medium">{person.name}</span>
                    <span className="text-xs text-muted-foreground">{person.role}</span>
                  </div>
                  <Check
                    className={cn(
                      "ml-auto h-4 w-4",
                      selectedId === person.id ? "opacity-100" : "opacity-0",
                    )}
                  />
                </CommandItem>
              ))}
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
}

API Reference

PropTypeDefaultDescription
optionsrequired
objectArray of { value: string, label: string, disabled?: boolean }
value
stringControlled selected option value
onChange
functionCallback when the user picks an option: (value: string) => void
placeholder
stringSelect…Text shown on the trigger when nothing is selected
searchPlaceholder
stringSearch…Placeholder for the filter input
emptyText
stringNo results found.Shown inside the list when the search has no matches
disabled
booleanfalseDisable the trigger
aria-label
stringAccessible label — required when no adjacent visible label is used
aria-labelledby
stringId of an external visible label that names the combobox

AI Guidance

When to use

Use for a select input when the list is >~8 items or users benefit from typing to narrow. Fuzzy search + keyboard nav + selected-item checkmark.

When not to use

Don't use for native-select parity on mobile (use Select). Don't use for multi-select (this component is single-value — compose Command + Popover yourself for multi). Don't use for free-text entry (use Input).

Common mistakes

  • Passing duplicate option values (breaks selection and filtering)
  • Two options with identical labels — cmdk dedupes by the Item's filter value (the label here), so one will be dropped from the list
  • Using the label as the value — fine if stable, but prefer a short stable `value` string
  • Forgetting to bind value + onChange — uncontrolled mode doesn't exist on this wrapper
  • Mixing translated labels without keying on value — label changes won't update selection
  • Missing aria-label / aria-labelledby — role='combobox' does not allow name from contents, so without one of these the trigger has no accessible name

Accessibility

Trigger has role='combobox' + aria-expanded + aria-haspopup='listbox'. aria-controls points at the inner CommandList (a useId-stabilized listbox). Pass aria-label or aria-labelledby — combobox does not derive its name from contents.

Related components

Token budget: 900