Combobox
An autocomplete input that combines a text input with a dropdown list of options.
Installation
$
Usage
import { Combobox } from "@/components/ui/combobox"Examples
Default
With Meta
Items can carry meta secondary text (shown in mono) and a disabled flag.
With Create
Pass createLabel and onCreate to surface a create action below the list.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| items* | ComboboxItem[] | - | The selectable options. Each item needs an id and label, plus optional meta (secondary text) and disabled. |
| value* | string | number | - | The id of the currently selected item. Matched against each item to show the check indicator. |
| onSelect* | (item: ComboboxItem) => void | - | Called with the full selected item. The popover closes after selection. |
| triggerLabel* | string | - | Display label for the trigger button. |
| triggerMeta | string | - | Secondary text shown after the trigger label. |
| searchable | boolean | false | Shows a search input above the list for filtering options. |
| searchPlaceholder | string | "Search..." | Placeholder for the search input when searchable is set. |
| emptyText | string | "No results." | Empty state text when no options match the search. |
| createLabel | string | - | Label for a create action pinned above the list. Requires onCreate; omit to hide. |
| onCreate | () => void | - | Called when the create action is clicked. The popover closes afterward. |
| align | "start" | "center" | "end" | "start" | Alignment of the popover against the trigger. |
| className | string | - | Extra classes for the trigger button. |
"use client"
import { useState } from "react"
import { ChevronDown, Plus, Check } from "lucide-react"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/ui/popover"
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/registry/ui/command"
import { cn } from "@/registry/lib/utils"
export interface ComboboxItem {
id: string | number
label: string
meta?: string
disabled?: boolean
}
interface ComboboxProps {
items: ComboboxItem[]
value: string | number
onSelect: (item: ComboboxItem) => void
/** Display label for the trigger */
triggerLabel: string
/** Secondary text shown after the label */
triggerMeta?: string
/** Show the search input field */
searchable?: boolean
/** Placeholder for the search input */
searchPlaceholder?: string
/** Empty state text when no results match */
emptyText?: string
/** Label for the create action (e.g. "New edition"). Omit to hide. */
createLabel?: string
/** Called when the create action is clicked */
onCreate?: () => void
/** Popover alignment */
align?: "start" | "center" | "end"
className?: string
}
export function Combobox({
items,
value,
onSelect,
triggerLabel,
triggerMeta,
searchable = false,
searchPlaceholder = "Search...",
emptyText = "No results.",
createLabel,
onCreate,
align = "start",
className,
}: ComboboxProps) {
const [open, setOpen] = useState(false)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<button
data-slot="combobox-trigger"
className={cn(
"group border-border flex w-fit min-w-48 items-center gap-2 rounded-action border bg-input-bg backdrop-blur-sm px-3 text-base md:text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-accent focus-visible:ring-ring-accent focus-visible:ring-1 h-8 cursor-pointer",
className
)}
>
<span>{triggerLabel}</span>
{triggerMeta && (
<span className="text-muted-foreground text-xs group-hover:text-inherit">
{triggerMeta}
</span>
)}
<ChevronDown className="ml-auto h-3 w-3 text-muted-foreground group-hover:text-inherit" />
</button>
}
/>
{/* `rounded-lg` overrides Popover's `rounded-md`: this popup is a MENU,
and menu popups take the rounder tier so their rows can sit at
`rounded-md` and stay concentric. A plain Popover holds prose, not
rows, so it keeps the tighter corner. `p-0` means Command is flush,
so the two radii have to agree exactly. */}
<PopoverContent
className="w-(--anchor-width) min-w-48 rounded-lg p-0"
align={align}
>
<Command>
{searchable && <CommandInput placeholder={searchPlaceholder} />}
{createLabel && onCreate && (
<div className="border-b border-border p-1">
<button
type="button"
onClick={() => {
onCreate()
setOpen(false)
}}
className="flex items-center gap-2 w-full px-2 py-1.5 pointer-coarse:py-3 text-sm rounded-md hover:bg-accent-100 dark:hover:bg-accent-900 hover:text-accent cursor-pointer"
>
<Plus className="h-3 w-3" />
{createLabel}
</button>
</div>
)}
<CommandList className="p-1">
<CommandEmpty>{emptyText}</CommandEmpty>
{items.map((item) => (
<CommandItem
key={item.id}
value={`${item.label} ${item.meta || ""}`}
onSelect={() => {
onSelect(item)
setOpen(false)
}}
disabled={item.disabled}
className="group/item"
>
<Check
className={cn(
"h-3 w-3 mr-2 group-data-[selected=true]/item:text-inherit",
value === item.id ? "opacity-100" : "opacity-0"
)}
/>
<span className="font-medium">{item.label}</span>
{item.meta && (
<span className="text-muted-foreground group-data-[selected=true]/item:text-inherit text-xs ml-auto font-mono">
{item.meta}
</span>
)}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}