Combobox
A searchable single-select composed from Popover and Command. Pass options, get fuzzy search and keyboard navigation for free.
import { Combobox } from "@/components/ui/combobox"
const regions = [
{ value: "iad", label: "us-east-1 · Virginia" },
{ value: "fra", label: "eu-central-1 · Frankfurt" },
{ value: "sin", label: "ap-south-1 · Singapore" },
]
export default function ComboboxDemo() {
return (
<div className="flex flex-wrap items-center gap-3">
<Combobox options={regions} placeholder="Select a region…" />
<Combobox options={regions} variant="soft" placeholder="Soft variant…" />
<Combobox options={regions} tone="danger" placeholder="Danger tone…" />
</div>
)
}Installation
Run the following command
npx shadcn@latest add @ai2/comboboxDependencies, the @ai2/tokens theme and the component file are installed together. The @ai2/button, @ai2/command and @ai2/popover components install alongside, since the combobox is composed from them.
Install dependencies
npm install lucide-react@^1.23.0Add the cn util
lib/utils.tsimport { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/* Adds a source-attribution ref param to a URL (the inspiration exports mark their
outbound links with an ai2.design attribution). An invalid URL is returned as is.
This file is SHOWN TO THE CONSUMER: the docs component pages render the source of
`cn` in a code block, so a Turkish comment here would reach every one of those
pages. Keep it English. */
export function withRef(url: string, ref = "ai2.design"): string {
try {
const u = new URL(url)
u.searchParams.set("ref", ref)
return u.toString()
} catch {
return url
}
}Copy the source code
components/ui/combobox.tsx"use client"
import * as React from "react"
import { Check, ChevronsUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface ComboboxOption {
value: string
label: string
disabled?: boolean
}
interface ComboboxProps
extends Omit<
React.ComponentProps<typeof Button>,
"value" | "defaultValue" | "onChange" | "variant" | "size" | "tone"
> {
options: ComboboxOption[]
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
placeholder?: string
searchPlaceholder?: string
emptyText?: string
variant?: "outline" | "soft" | "ghost"
size?: "sm" | "md" | "lg"
tone?: "neutral" | "success" | "danger"
}
const comboboxToneClasses: Record<NonNullable<ComboboxProps["tone"]>, string> = {
neutral: "",
success:
"border border-success focus-visible:border-success focus-visible:ring-success/20 dark:focus-visible:ring-success/40",
danger:
"border border-danger focus-visible:border-danger focus-visible:ring-danger/20 dark:focus-visible:ring-danger/40",
}
function Combobox({
options,
value: valueProp,
defaultValue = "",
onValueChange,
placeholder = "Select an option…",
searchPlaceholder = "Search…",
emptyText = "No results found.",
className,
disabled,
variant = "outline",
size = "md",
tone = "neutral",
...props
}: ComboboxProps) {
const [open, setOpen] = React.useState(false)
const [internal, setInternal] = React.useState(defaultValue)
const value = valueProp ?? internal
const select = (next: string) => {
const resolved = next === value ? "" : next
setInternal(resolved)
onValueChange?.(resolved)
setOpen(false)
}
const selected = options.find((o) => o.value === value)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant={variant}
size={size}
role="combobox"
aria-expanded={open}
disabled={disabled}
data-slot="combobox-trigger"
data-tone={tone}
className={cn(
"w-56 justify-between font-normal",
comboboxToneClasses[tone],
className
)}
{...props}
>
<span className={cn("truncate", !selected && "text-muted-foreground")}>
{selected ? selected.label : placeholder}
</span>
<ChevronsUpDown className="text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-0">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
disabled={option.disabled}
onSelect={select}
>
<Check
className={cn(
"size-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
export { Combobox, type ComboboxOption, type ComboboxProps }The combobox imports the ai2 button, command and popover, so copy those files as well. Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Usage
import { Combobox } from "@/components/ui/combobox"
const frameworks = [
{ value: "next", label: "Next.js" },
{ value: "astro", label: "Astro" },
{ value: "remix", label: "Remix" },
]
<Combobox options={frameworks} placeholder="Select a framework…" />The combobox works uncontrolled out of the box; pass value and onValueChange to control it. Selecting the active option again clears the value.
Examples
Default value
Disabled
Disable the whole combobox with disabled, or a single option with disabled: true in its options entry, here the Enterprise plan.
Variants
The trigger follows the shared input contract: variant, size and tone match the ai2 Input and Select.
Sizes
Tones
tone colors the trigger border and focus ring for validation states, matching the ai2 Input and Select.
Custom text
Props
Combobox is a closed composition. It exposes exactly these props (plus the ComboboxOption type).
| Prop | Type | Default | Description |
|---|---|---|---|
options | ComboboxOption[] | - | The selectable options as { value, label, disabled? } objects. Required. |
value | string | - | Controlled selected value. Pair with onValueChange. |
defaultValue | string | "" | Initial value for uncontrolled usage. |
onValueChange | (value: string) => void | - | Called with the new value on selection, or with an empty string when the active option is selected again (clearing it). |
placeholder | string | "Select an option…" | Trigger text while nothing is selected. |
searchPlaceholder | string | "Search…" | Placeholder of the search input. |
emptyText | string | "No results found." | Message shown when the search matches no option. |
disabled | boolean | - | Disables the trigger button. |
variant | "outline" | "soft" | "ghost" | "outline" | Visual style of the trigger: bordered, filled or transparent. |
size | "sm" | "md" | "lg" | "md" | Height of the trigger button. |
tone | "neutral" | "success" | "danger" | "neutral" | Validation look: colors the trigger border and focus ring. |
className | string | - | Extra classes for the trigger button (default width is w-56). |
ai2 Combobox: a searchable select for React, built from Popover and Command
The ai2 Combobox is a shadcn-compatible combobox component for React, a searchable single-select composed from the ai2 Popover, Command and Button and styled with Tailwind CSS v4. You pass an array of value and label pairs; it handles the open state, text filtering, keyboard navigation and the selected check mark, so long option lists like countries, regions, models or team members stay usable.
Because it ships through the shadcn registry format, you install it with one CLI command, an MCP agent, or a copy-paste, and the source lands in your own project along with the button, command and popover it is built from. You own every file; there is no runtime dependency on ai2 itself. The live example above is the exact component you get.
What is the ai2 Combobox?
Unlike most registry items, it is a closed composition: a single Combobox component that wires an ai2 Button trigger into a Popover holding a cmdk-powered Command list. This is the same pattern the shadcn/ui docs describe for building a combobox, packaged as one component with a typed options prop.
It works uncontrolled with defaultValue or controlled with value and onValueChange. Selecting the active option again clears the value, per option disabling is a flag on the options entry, and the popover panel matches the trigger width through the radix trigger-width variable. The trigger follows the shared ai2 input contract: a variant of outline, soft or ghost, a size of sm, md or lg, and a tone of neutral, success or danger that colors the border and focus ring for validation states.
Why use it
- Search built in: The cmdk-powered list filters as the user types, so a 200-entry option list stays as fast to use as a five-entry one.
- One prop, whole behavior: Pass options as { value, label, disabled? } objects and the component handles open state, filtering, selection and the check indicator.
- Accessible trigger semantics: The trigger is a real button with role="combobox" and aria-expanded, and the list inherits cmdk keyboard navigation: arrows move, Enter selects, Escape closes.
- Controlled or uncontrolled: Use defaultValue for simple forms, or drive value and onValueChange from React state when other UI depends on the selection.
- Composed from parts you own: Installing @ai2/combobox brings the ai2 button, command and popover with it. If you outgrow the closed API, open the source and extend the composition directly.
Features
- shadcn registry install: One command adds the combobox plus its button, command and popover dependencies and the @ai2/tokens theme.
- 3 variants, 3 sizes and 3 tones: The trigger offers outline, soft and ghost variants, sm, md and lg sizes, and neutral, success and danger tones, aligned with the ai2 Input and Select contract.
- Clear-on-reselect: Selecting the active option again resets the value to an empty string, giving users a way to clear without a separate button.
- Per-option disabling: Set disabled: true on any options entry to show it muted and unselectable, for example a plan the workspace cannot choose.
- Trigger-width panel: The popover content uses the radix trigger-width variable, so the list always lines up with the button regardless of the className you set.
- Customizable copy: placeholder, searchPlaceholder and emptyText are plain string props, so localization needs no source edits.
- TypeScript source: ComboboxProps and ComboboxOption are exported, typed end to end, so autocomplete covers the full API.
Production tips
- Use it when lists get long: Under roughly ten options, a plain ai2 Select is simpler. The combobox earns its search box on long or unfamiliar lists like countries or users.
- Keep labels searchable: Filtering matches what users type against the option text. Front-load the meaningful part of each label instead of prefixing every entry with the same word.
- Give it an accessible name: The trigger only announces the selected label. Associate a visible label with aria-labelledby, or set aria-label when the design has no room for one.
- Handle the cleared state: Reselecting the active option calls onValueChange with an empty string. Make sure your form logic treats that as unset rather than crashing on it.
- Size the trigger deliberately: The default width is w-56 and long labels truncate. Pass a wider className when option labels run long, and the panel follows automatically.
Works with the rest of ai2
The combobox is literally built from ai2 Popover, ai2 Command and ai2 Button, so reading those pages explains its internals. In forms, pair it with an ai2 Field for label, description and error wiring.
When the option list is short and search adds nothing, use the ai2 Select instead. Both share the same popover surface and token source, so swapping one for the other later is a local change.