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

1 Configure registry — once per project
{
  "registries": {
    "@designrevision": "https://registry.designrevision.com/r/{name}.json"
  }
}

Add to your existing components.json. Requires shadcn/ui v2.3+.

2 Install the component

Packages

lucide-react
utils
popover
command
button

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.

Copied!
components/ui/combobox.tsx
"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 `` — a searchable single-select built from an `options` array with a controlled value.

Packages

@designrevision/combobox

Props

No props documented yet.

Copied!
components/ui/combobox-demo-01.tsx
"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

lucide-react
popover
command
button

Props

No props documented yet.

Copied!
components/ui/combobox-composition-01.tsx
"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

@designrevision/combobox
button
label

Props

No props documented yet.

Copied!
components/ui/combobox-form-01.tsx
"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

lucide-react
popover
command
button

Props

No props documented yet.

Copied!
components/ui/combobox-groups-01.tsx
"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

lucide-react
popover
command
button

Props

No props documented yet.

Copied!
components/ui/combobox-icons-01.tsx
"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

popover
command
@designrevision/drawer
button

Props

No props documented yet.

Copied!
components/ui/combobox-responsive-01.tsx
"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

lucide-react
popover
command
button

Props

No props documented yet.

Copied!
components/ui/combobox-async-01.tsx
"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 ""** when the search matches nothing — add on the fly.

Packages

lucide-react
popover
command
button

Props

No props documented yet.

Copied!
components/ui/combobox-create-01.tsx
"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>
  )
}

Frequently asked questions

Officially the Combobox is a composition pattern, not a primitive — you build it from Popover + Command. We ship both: a drop-in component for the common single-select case (the Installation snippet above) and the raw Popover + Command composition (the Raw composition example) for full control over grouping, icons, async, and create-new.
Effectively yes. shadcn has no separate Autocomplete component — the Combobox is the searchable select / autocomplete: type to filter a list and pick a value. If you want free-text entry that also accepts new values, see the Create new option example.
Use a [Select](/components/select) for a short, static list where scanning is easy. Use a Combobox when the list is long or the user benefits from typing to filter (countries, frameworks, users, timezones). The Combobox adds a search input; the Select does not.
Radix exposes the trigger width to the popover as a CSS variable. Put className="w-[var(--radix-popover-trigger-width)]" on PopoverContent and it will always match the trigger. Our wrapper does this for you; the raw-composition examples use a fixed width (e.g. w-[240px]) for clarity.
Set shouldFilter={false} on Command so cmdk stops filtering locally, drive CommandInput as a controlled value, debounce a request on change, and render the results you get back with a loading row in between. The Async / server search example simulates the full round-trip.
This Combobox is single-select. For multiple selection with removable badges, use the dedicated [Multi-Select](/components/multi-select) component — it is built on the same Popover + Command foundation.
Render a Popover above your breakpoint and a [Drawer](/components/drawer) below it with a useMediaQuery hook, sharing the same Command list. The Responsive example shows the pattern shadcn documents for the Combobox.