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
A searchable picker for a list too long to scan. Items are keyed by id, not value, and onSelect hands back the whole item rather than just its id.
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 |
|---|---|---|---|
| size | "xs" | "sm" | "default" | "lg" | "default" | Control height on the shared Button/Input ladder: 24 / 28 / 32 / 40, each with its matching action-radius rung. The same size on a Combobox, an Input and a Button gives one shape at one height. |
| 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 { IconChevronDown as ChevronDown, IconPlus as Plus, IconCheck as Check } from "@tabler/icons-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"
// Mirrors Input's ladder exactly, which mirrors Button's: the same `size` name
// gives the same height and the matching rung off the ACTION radius ramp, so a
// Combobox, an Input and a Button set to the same size are one shape at one
// height. This used to be hardcoded `h-8`, which meant a Combobox could not
// join a row of `lg` controls (a toolbar carrying a TabsList, whose list is
// trigger + p-1 = 40px) without sitting 8px short.
const SIZE_CLASSES = {
xs: "h-6 px-2 rounded-action-xs text-base md:text-1xs",
sm: "h-7 px-3 rounded-action-sm text-base md:text-sm",
default: "h-8 px-3 rounded-action text-base md:text-sm",
lg: "h-10 px-4 rounded-action-lg text-base md:text-sm",
} as const
type ComboboxSize = keyof typeof SIZE_CLASSES
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"
/** Put on the trigger button so a `<Label htmlFor>` can attach to it. The
* trigger is a `<button>`, which IS a labelable element, so this is the
* whole association: without it a Combobox cannot be labelled at all, since
* the component takes no other passthrough. */
id?: string
/** Control height, on the shared Button/Input ladder: 24 / 28 / 32 / 40. */
size?: ComboboxSize
className?: string
}
// @use-when a text field that filters a list of options as you type.
export function Combobox({
items,
value,
onSelect,
triggerLabel,
triggerMeta,
searchable = false,
searchPlaceholder = "Search...",
emptyText = "No results.",
createLabel,
onCreate,
align = "start",
id,
size = "default",
className,
}: ComboboxProps) {
const [open, setOpen] = useState(false)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<button
id={id}
data-slot="combobox-trigger"
className={cn(
// Matches SelectTrigger's touch policy: the 32px box gets no
// floor otherwise, so bump it to the 44px target under a
// coarse pointer.
"group border-border flex w-fit min-w-48 items-center gap-2 border bg-input-bg backdrop-blur-sm font-medium shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-accent focus-visible:ring-ring-accent focus-visible:ring-1 pointer-coarse:min-h-11 cursor-pointer",
SIZE_CLASSES[size],
className
)}
>
<span>{triggerLabel}</span>
{triggerMeta && (
<span className="text-muted-foreground text-xs group-hover:text-inherit">
{triggerMeta}
</span>
)}
<ChevronDown aria-hidden="true" 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-800 hover:text-accent dark:hover:text-accent-400 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>
)
}