Shadcn Combobox
Free shadcn/ui combobox for React — the searchable select / autocomplete built from Popover + Command. A drop-in component plus raw composition: grouped options, icons, async search, create-new, and a responsive Drawer. The examples official shadcn never shipped.
Installation
{
"registries": {
"@designrevision": "https://registry.designrevision.com/r/{name}.json"
}
}
Add to your existing components.json. Requires shadcn/ui v2.3+.
Packages
Props
options*
= —
{ value: string; label: string; disabled?: boolean }[]
The selectable options. label is shown and searched; value is returned on select.
value
= —
string
Controlled selected value. Selecting the active option again clears it.
onValueChange
= —
(value: string) => void
Fires with the new value (empty string when cleared).
placeholder
= "Select an option…"
string
Trigger text shown when nothing is selected.
searchPlaceholder
= "Search…"
string
Placeholder for the search input.
emptyText
= "No results found."
string
Message shown when the search matches nothing.
A reusable single-select combobox wrapping Popover + Command + Button (cmdk-powered search), with the popover width matched to the trigger via --radix-popover-trigger-width. For grouped options, icons, async search, or create-new, compose Popover + Command directly (see the examples); for multiple selection use the Multi-Select component.
"use client"
import * as React from "react"
import { CheckIcon, ChevronsUpDownIcon } 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"
export interface ComboboxOption {
value: string
label: string
disabled?: boolean
}
export interface ComboboxProps {
options: ComboboxOption[]
value?: string
onValueChange?: (value: string) => void
placeholder?: string
searchPlaceholder?: string
emptyText?: string
className?: string
disabled?: boolean
id?: string
}
function Combobox({
options,
value,
onValueChange,
placeholder = "Select an option…",
searchPlaceholder = "Search…",
emptyText = "No results found.",
className,
disabled,
id,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false)
const selected = options.find((option) => option.value === value)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("w-full justify-between font-normal", className)}
>
<span
className={cn("truncate", !selected && "text-muted-foreground")}
>
{selected ? selected.label : placeholder}
</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
disabled={option.disabled}
onSelect={() => {
onValueChange?.(option.value === value ? "" : option.value)
setOpen(false)
}}
>
<CheckIcon
className={cn(
"mr-2 size-4",
option.value === value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
export { Combobox }
Examples
Combobox
The drop-in `
Packages
Props
No props documented yet.
"use client"
import * as React from "react"
import { Combobox } from "@/components/ui/combobox"
const frameworks = [
{ value: "next.js", label: "Next.js" },
{ value: "sveltekit", label: "SvelteKit" },
{ value: "nuxt.js", label: "Nuxt.js" },
{ value: "remix", label: "Remix" },
{ value: "astro", label: "Astro" },
]
export default function ComboboxDemo01() {
const [value, setValue] = React.useState("")
return (
<div className="w-[240px]">
<Combobox
options={frameworks}
value={value}
onValueChange={setValue}
placeholder="Select framework…"
searchPlaceholder="Search framework…"
emptyText="No framework found."
/>
</div>
)
}
Raw composition
The canonical shadcn pattern — [Popover](/components/popover) + [Command](/components/command) composed by hand for full control over the trigger and items.
Packages
Props
No props documented yet.
"use client"
import * as React from "react"
import { CheckIcon, ChevronsUpDownIcon } 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"
const frameworks = [
{ value: "next.js", label: "Next.js" },
{ value: "sveltekit", label: "SvelteKit" },
{ value: "nuxt.js", label: "Nuxt.js" },
{ value: "remix", label: "Remix" },
{ value: "astro", label: "Astro" },
]
export default function ComboboxComposition01() {
const [open, setOpen] = React.useState(false)
const [value, setValue] = React.useState("")
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[240px] justify-between font-normal"
>
{value
? frameworks.find((f) => f.value === value)?.label
: "Select framework…"}
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="start">
<Command>
<CommandInput placeholder="Search framework…" />
<CommandList>
<CommandEmpty>No framework found.</CommandEmpty>
<CommandGroup>
{frameworks.map((framework) => (
<CommandItem
key={framework.value}
value={framework.label}
onSelect={() => {
setValue(framework.value === value ? "" : framework.value)
setOpen(false)
}}
>
<CheckIcon
className={cn(
"mr-2 size-4",
value === framework.value ? "opacity-100" : "opacity-0"
)}
/>
{framework.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
In a form
A labelled, controlled combobox inside a form that reports the selected value on submit.
Packages
Props
No props documented yet.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Combobox } from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"
const languages = [
{ value: "en", label: "English" },
{ value: "fr", label: "French" },
{ value: "de", label: "German" },
{ value: "es", label: "Spanish" },
{ value: "pt", label: "Portuguese" },
]
export default function ComboboxForm01() {
const [language, setLanguage] = React.useState("")
const [submitted, setSubmitted] = React.useState<string | null>(null)
return (
<form
onSubmit={(event) => {
event.preventDefault()
setSubmitted(
languages.find((l) => l.value === language)?.label ?? "nothing"
)
}}
className="grid w-[260px] gap-3"
>
<div className="grid gap-2">
<Label htmlFor="language">Language</Label>
<Combobox
id="language"
options={languages}
value={language}
onValueChange={setLanguage}
placeholder="Select language…"
searchPlaceholder="Search language…"
emptyText="No language found."
/>
<p className="text-sm text-muted-foreground">
This is the language used across your dashboard.
</p>
</div>
<Button type="submit" className="w-fit">
Save
</Button>
{submitted && (
<p className="text-sm">
Saved: <span className="font-medium">{submitted}</span>
</p>
)}
</form>
)
}
Grouped options
Options organised under category headings with `CommandGroup`.
Packages
Props
No props documented yet.
"use client"
import * as React from "react"
import { CheckIcon, ChevronsUpDownIcon } 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"
const groups = [
{
label: "Fruits",
items: [
{ value: "apple", label: "Apple" },
{ value: "banana", label: "Banana" },
{ value: "orange", label: "Orange" },
],
},
{
label: "Vegetables",
items: [
{ value: "carrot", label: "Carrot" },
{ value: "potato", label: "Potato" },
{ value: "spinach", label: "Spinach" },
],
},
]
const allItems = groups.flatMap((group) => group.items)
export default function ComboboxGroups01() {
const [open, setOpen] = React.useState(false)
const [value, setValue] = React.useState("")
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[240px] justify-between font-normal"
>
{value
? allItems.find((item) => item.value === value)?.label
: "Select produce…"}
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="start">
<Command>
<CommandInput placeholder="Search produce…" />
<CommandList>
<CommandEmpty>No produce found.</CommandEmpty>
{groups.map((group) => (
<CommandGroup key={group.label} heading={group.label}>
{group.items.map((item) => (
<CommandItem
key={item.value}
value={item.label}
onSelect={() => {
setValue(item.value === value ? "" : item.value)
setOpen(false)
}}
>
<CheckIcon
className={cn(
"mr-2 size-4",
value === item.value ? "opacity-100" : "opacity-0"
)}
/>
{item.label}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
Icons & status
A status picker with an icon in both the trigger and each item — the issue-tracker pattern.
Packages
Props
No props documented yet.
"use client"
import * as React from "react"
import {
CheckIcon,
ChevronsUpDownIcon,
CircleCheckIcon,
CircleDotIcon,
CircleIcon,
CircleXIcon,
} 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"
const statuses = [
{ value: "backlog", label: "Backlog", icon: CircleIcon },
{ value: "in-progress", label: "In Progress", icon: CircleDotIcon },
{ value: "done", label: "Done", icon: CircleCheckIcon },
{ value: "canceled", label: "Canceled", icon: CircleXIcon },
]
export default function ComboboxIcons01() {
const [open, setOpen] = React.useState(false)
const [value, setValue] = React.useState("")
const selected = statuses.find((status) => status.value === value)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[220px] justify-between font-normal"
>
<span className="flex min-w-0 items-center gap-2">
{selected && <selected.icon className="size-4 shrink-0" />}
<span className={cn("truncate", !selected && "text-muted-foreground")}>
{selected ? selected.label : "Select status…"}
</span>
</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="Change status…" />
<CommandList>
<CommandEmpty>No status found.</CommandEmpty>
<CommandGroup>
{statuses.map((status) => (
<CommandItem
key={status.value}
value={status.label}
onSelect={() => {
setValue(status.value === value ? "" : status.value)
setOpen(false)
}}
>
<status.icon className="size-4" />
<span>{status.label}</span>
<CheckIcon
className={cn(
"ml-auto size-4",
value === status.value ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
Responsive (Popover + Drawer)
A Popover on desktop and a [Drawer](/components/drawer) on mobile via `useMediaQuery`, sharing one Command list.
Packages
Props
No props documented yet.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
type Status = { value: string; label: string }
const statuses: Status[] = [
{ value: "backlog", label: "Backlog" },
{ value: "todo", label: "Todo" },
{ value: "in-progress", label: "In Progress" },
{ value: "done", label: "Done" },
{ value: "canceled", label: "Canceled" },
]
function useMediaQuery(query: string) {
const [matches, setMatches] = React.useState(false)
React.useEffect(() => {
const result = window.matchMedia(query)
const onChange = () => setMatches(result.matches)
onChange()
result.addEventListener("change", onChange)
return () => result.removeEventListener("change", onChange)
}, [query])
return matches
}
function StatusList({
setOpen,
setSelected,
}: {
setOpen: (open: boolean) => void
setSelected: (status: Status | null) => void
}) {
return (
<Command>
<CommandInput placeholder="Filter status…" />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{statuses.map((status) => (
<CommandItem
key={status.value}
value={status.value}
onSelect={(value) => {
setSelected(statuses.find((s) => s.value === value) ?? null)
setOpen(false)
}}
>
{status.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
)
}
export default function ComboboxResponsive01() {
const [open, setOpen] = React.useState(false)
const [selected, setSelected] = React.useState<Status | null>(null)
const isDesktop = useMediaQuery("(min-width: 768px)")
const label = selected ? selected.label : "+ Set status"
if (isDesktop) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-[200px] justify-start font-normal">
{label}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<StatusList setOpen={setOpen} setSelected={setSelected} />
</PopoverContent>
</Popover>
)
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button variant="outline" className="w-[200px] justify-start font-normal">
{label}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="sr-only">
<DrawerTitle>Set status</DrawerTitle>
</DrawerHeader>
<div className="mt-4 border-t">
<StatusList setOpen={setOpen} setSelected={setSelected} />
</div>
</DrawerContent>
</Drawer>
)
}
Async / server search
Server-side search with `shouldFilter={false}`, a debounced request, and a loading spinner.
Packages
Props
No props documented yet.
"use client"
import * as React from "react"
import { ChevronsUpDownIcon, Loader2Icon } from "lucide-react"
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"
const ALL = [
"Next.js",
"React",
"Vue",
"Svelte",
"Angular",
"Solid",
"Astro",
"Remix",
"Nuxt",
"Qwik",
]
export default function ComboboxAsync01() {
const [open, setOpen] = React.useState(false)
const [value, setValue] = React.useState("")
const [query, setQuery] = React.useState("")
const [results, setResults] = React.useState<string[]>([])
const [loading, setLoading] = React.useState(false)
React.useEffect(() => {
if (!query) {
setResults([])
setLoading(false)
return
}
// Simulate a debounced server request.
setLoading(true)
const id = setTimeout(() => {
setResults(ALL.filter((f) => f.toLowerCase().includes(query.toLowerCase())))
setLoading(false)
}, 500)
return () => clearTimeout(id)
}, [query])
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[240px] justify-between font-normal"
>
<span className={value ? "" : "text-muted-foreground"}>
{value || "Search frameworks…"}
</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Type to search (simulated)…"
/>
<CommandList>
{loading && (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
Searching…
</div>
)}
{!loading && query && results.length === 0 && (
<CommandEmpty>No results found.</CommandEmpty>
)}
{!loading && results.length > 0 && (
<CommandGroup>
{results.map((framework) => (
<CommandItem
key={framework}
value={framework}
onSelect={(current) => {
setValue(current === value ? "" : framework)
setOpen(false)
}}
>
{framework}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
Create new option
A tag picker that offers **Create "
Packages
Props
No props documented yet.
"use client"
import * as React from "react"
import { CheckIcon, ChevronsUpDownIcon, PlusIcon } 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"
export default function ComboboxCreate01() {
const [open, setOpen] = React.useState(false)
const [query, setQuery] = React.useState("")
const [value, setValue] = React.useState("")
const [options, setOptions] = React.useState([
{ value: "design", label: "Design" },
{ value: "engineering", label: "Engineering" },
{ value: "marketing", label: "Marketing" },
])
const filtered = options.filter((option) =>
option.label.toLowerCase().includes(query.toLowerCase())
)
const exists = options.some(
(option) => option.label.toLowerCase() === query.trim().toLowerCase()
)
const selected = options.find((option) => option.value === value)
const createOption = () => {
const label = query.trim()
if (!label) return
const next = label.toLowerCase().replace(/\s+/g, "-")
setOptions((prev) => [...prev, { value: next, label }])
setValue(next)
setQuery("")
setOpen(false)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[240px] justify-between font-normal"
>
<span className={selected ? "" : "text-muted-foreground"}>
{selected ? selected.label : "Select or create tag…"}
</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Search or add a tag…"
/>
<CommandList>
{filtered.length === 0 && !query.trim() && (
<CommandEmpty>No tags found.</CommandEmpty>
)}
<CommandGroup>
{filtered.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => {
setValue(option.value === value ? "" : option.value)
setOpen(false)
}}
>
<CheckIcon
className={cn(
"mr-2 size-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
{query.trim() && !exists && (
<CommandItem value="__create__" onSelect={createOption}>
<PlusIcon className="mr-2 size-4" />
Create “{query.trim()}”
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
The Shadcn Combobox is the searchable select — an autocomplete built from a Popover and a Command menu. Officially shadcn ships it as a composition pattern rather than a component; we give you both — a drop-in <Combobox> for the common case and the raw Popover + Command composition for everything custom: grouped options, icons, async search, create-new, and a responsive Drawer on mobile.
Component or composition?
There's no canonical Combobox primitive in shadcn — it's Popover + Command wired together. That's flexible but repetitive, so we provide a small reusable <Combobox> that covers the 80% case:
<Combobox
options={frameworks}
value={value}
onValueChange={setValue}
placeholder="Select framework…"
/>
Reach for the raw composition (shown in every example below the first) when you need a custom trigger, grouped headings, icons, async loading, or a create-new action. Both render the same UI — pick the level of control you want.
Combobox vs Autocomplete vs Select
These names overlap, so to be concrete:
- Select — a short, static list; no typing. Best when there are only a handful of options.
- Combobox / Autocomplete — a searchable list; type to filter, then pick. shadcn has no separate "Autocomplete" — the Combobox is it. Best for long lists (countries, frameworks, users).
- Multi-Select — a Combobox that keeps several values as removable badges.
Matching the popover to the trigger width
The one detail every combobox needs: the dropdown should be as wide as the button. Radix exposes the trigger width to the popover as a CSS variable, so set it on PopoverContent:
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
Our <Combobox> wrapper applies this automatically; the raw examples use a fixed width for clarity.
Grouped options
Wrap sets of items in CommandGroup with a heading to organise a long list into sections — the Grouped options example splits produce into Fruits and Vegetables. cmdk keeps the headings while filtering.
Icons & status
A combobox isn't just text — render an icon in both the trigger and each item for status pickers, country flags, or user avatars. The Icons & status example is the issue-tracker pattern (Backlog / In Progress / Done / Canceled).
Responsive (Popover + Drawer)
On a phone, a popover anchored to a button is cramped. The pattern shadcn documents is a Popover on desktop and a Drawer on mobile: detect the viewport with a useMediaQuery hook and render the same Command list in whichever container fits. The Responsive example is the full implementation.
Async / server-side search
For options that live on a server, set shouldFilter={false} so cmdk stops filtering locally, drive CommandInput as a controlled value, debounce a request, and render the results you receive — with a loading spinner in between. The Async / server search example simulates the round-trip end to end.
Create-new option
When the search matches nothing, let the user add it: offer a "Create 'shouldFilter={false} list you control.
Accessibility
The trigger is a Button with role="combobox" and aria-expanded, so it's announced correctly; cmdk handles arrow-key navigation, type-ahead, and Enter to select inside the list. Always give the popover a searchable CommandInput and a CommandEmpty fallback so keyboard and screen-reader users get feedback when nothing matches.
Theming
The combobox reads your design tokens — --popover, --accent (the highlighted item), --input (the trigger border) — so it follows your theme, including dark mode, with no overrides.