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 dlx @hex-core/cli add comboboxFiltered 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.
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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
optionsrequired | object | — | Array of { value: string, label: string, disabled?: boolean } |
value | string | — | Controlled selected option value |
onChange | function | — | Callback when the user picks an option: (value: string) => void |
placeholder | string | Select… | Text shown on the trigger when nothing is selected |
searchPlaceholder | string | Search… | Placeholder for the filter input |
emptyText | string | No results found. | Shown inside the list when the search has no matches |
disabled | boolean | false | Disable the trigger |
aria-label | string | — | Accessible label — required when no adjacent visible label is used |
aria-labelledby | string | — | Id 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.
Token budget: 900
Verified against @hex-core/components@1.12.0