Use these recipes when users need to search a list before selecting options. The single-selection examples compose Popover and Command; the multiple-selection example uses Base UI Combobox so selected values remain distinct from the current keyboard position. These are editable recipes, not a standalone Combobox registry endpoint.
"use client";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import * as React from "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";
import { cn } from "@/lib/utils";
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 FrameworkCombobox() {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState("");
const contentId = React.useId();
return (
<Popover open={open} onOpenChange={setOpen}>
{/* A button-backed select-only combobox follows the ARIA combobox pattern. */}
<PopoverTrigger
render={
<Button
variant="noShadow"
role="combobox"
aria-label="Select a framework"
aria-expanded={open}
aria-controls={open ? contentId : undefined}
className="w-full justify-between md:max-w-[200px]"
/>
}
>
{value
? frameworks.find((framework) => framework.value === value)?.label
: "Select framework..."}
<ChevronsUpDown />
</PopoverTrigger>
<PopoverContent
id={contentId}
aria-label="Choose a framework"
className="w-(--anchor-width) border-0 p-0"
>
<Command className="**:data-[slot=command-input-wrapper]:h-11">
<CommandInput placeholder="Search framework..." />
<CommandList className="p-1">
<CommandEmpty>No framework found.</CommandEmpty>
<CommandGroup>
{frameworks.map((framework) => (
<CommandItem
key={framework.value}
value={framework.value}
onSelect={(currentValue) => {
setValue(currentValue === value ? "" : currentValue);
setOpen(false);
}}
>
{framework.label}
<CheckIcon
className={cn(
"ml-auto",
value === framework.value ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
#Installation
Complete the base setup, then install Button, Popover, and Command. Copy the Usage recipe into your application.
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/button.json https://neobrutal-ui.andongmin.com/r/popover.json https://neobrutal-ui.andongmin.com/r/command.jsonThe multiple-selection example only needs Button and @base-ui/react, which the shared base already installs.
It does not use Command or Popover.
#Usage
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
const options = ["React", "Vue", "Svelte"];
export default function FrameworkPicker() {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button variant="outline" role="combobox" aria-expanded={open} aria-label="Framework" />
}
>
{value || "Choose framework"}
</PopoverTrigger>
<PopoverContent aria-label="Choose a framework" className="w-64 p-0">
<Command>
<CommandInput placeholder="Search frameworks…" aria-label="Search frameworks" />
<CommandList>
<CommandEmpty>No framework found.</CommandEmpty>
{options.map((option) => (
<CommandItem
key={option}
value={option}
onSelect={() => {
setValue(option);
setOpen(false);
}}
>
{option}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}#API reference
| Recipe state / part | Purpose | Default |
|---|---|---|
open / setOpen | Popover visibility | false |
value / setValue | Selected option | Empty string |
CommandInput | Filters the list | Empty query |
CommandItem.value | Searchable option identity | Explicit in this recipe |
CommandItem.onSelect | Save selection and close | Application callback |
CommandEmpty | No-match feedback | — |
#Accessibility
Name the selection control and search field. Keep the current selection visible in the trigger. Verify keyboard opening, list navigation, selection, dismissal, and focus return. Do not use a searchable popup for unrestricted free-text entry; these recipes select from predefined options. For server search, add loading and error states and disable client filtering when the server owns results.
Command filters each item's value and keywords. When a stable ID differs from its visible label, keep the ID as value and pass the label in keywords, as the Users and Timezones examples do. This lets users search names or GMT offsets while selection still returns the original ID.
The single-selection popup is a dialog containing the search field and listbox. Give PopoverContent an accessible name and retain PopoverTrigger's native aria-haspopup="dialog"; its aria-controls must reference the popup, rather than the nested listbox.
For multiple selection, use Combobox.Root with multiple and optionally control its array-valued value and onValueChange.
Base UI manages listbox selection, the active descendant, filtering and focus; do not replace these with Command's single current-item state.
#Examples
#Users
This example additionally uses Avatar. Install it before copying the Code:
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/avatar.json"use client";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import * as React from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
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";
import { cn } from "@/lib/utils";
const users = [
{
id: "1",
username: "johndoe",
initials: "JD",
},
{
id: "2",
username: "janedoe",
initials: "JD",
},
{
id: "3",
username: "alexsmith",
initials: "AS",
},
] as const;
export default function UserCombobox() {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState("1");
const contentId = React.useId();
const selectedUser = React.useMemo(() => users.find((user) => user.id === value), [value]);
return (
<Popover open={open} onOpenChange={setOpen}>
{/* A button-backed select-only combobox follows the ARIA combobox pattern. */}
<PopoverTrigger
render={
<Button
variant="noShadow"
role="combobox"
aria-label="Select a user"
aria-expanded={open}
aria-controls={open ? contentId : undefined}
className="w-full justify-between px-2 md:max-w-[200px]"
/>
}
>
{selectedUser ? (
<div className="flex items-center gap-2">
<Avatar className="size-5">
<AvatarFallback>{selectedUser.initials}</AvatarFallback>
</Avatar>
{selectedUser.username}
</div>
) : (
"Select user..."
)}
<ChevronsUpDown className="text-muted-foreground" />
</PopoverTrigger>
<PopoverContent
id={contentId}
aria-label="Choose a user"
className="w-(--anchor-width) border-0 p-0"
>
<Command className="**:data-[slot=command-input-wrapper]:h-11">
<CommandInput placeholder="Search user..." />
<CommandList className="p-1">
<CommandEmpty>No user found.</CommandEmpty>
<CommandGroup className="[&_[cmdk-group-items]]:flex [&_[cmdk-group-items]]:flex-col [&_[cmdk-group-items]]:gap-1">
{users.map((user) => (
<CommandItem
key={user.id}
value={user.id}
keywords={[user.username]}
onSelect={(currentValue) => {
setValue(currentValue === value ? "" : currentValue);
setOpen(false);
}}
>
<Avatar className="size-5">
<AvatarFallback>{user.initials}</AvatarFallback>
</Avatar>
{user.username}
<CheckIcon
className={cn("ml-auto", value === user.id ? "opacity-100" : "opacity-0")}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
#Timezones
"use client";
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import * as React from "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 timezones = [
{
label: "Americas",
timezones: [
{ value: "America/New_York", label: "(GMT-5) New York" },
{ value: "America/Los_Angeles", label: "(GMT-8) Los Angeles" },
{ value: "America/Chicago", label: "(GMT-6) Chicago" },
{ value: "America/Toronto", label: "(GMT-5) Toronto" },
{ value: "America/Vancouver", label: "(GMT-8) Vancouver" },
{ value: "America/Sao_Paulo", label: "(GMT-3) São Paulo" },
],
},
{
label: "Europe",
timezones: [
{ value: "Europe/London", label: "(GMT+0) London" },
{ value: "Europe/Paris", label: "(GMT+1) Paris" },
{ value: "Europe/Berlin", label: "(GMT+1) Berlin" },
{ value: "Europe/Rome", label: "(GMT+1) Rome" },
{ value: "Europe/Madrid", label: "(GMT+1) Madrid" },
{ value: "Europe/Amsterdam", label: "(GMT+1) Amsterdam" },
],
},
{
label: "Asia/Pacific",
timezones: [
{ value: "Asia/Tokyo", label: "(GMT+9) Tokyo" },
{ value: "Asia/Shanghai", label: "(GMT+8) Shanghai" },
{ value: "Asia/Singapore", label: "(GMT+8) Singapore" },
{ value: "Asia/Dubai", label: "(GMT+4) Dubai" },
{ value: "Australia/Sydney", label: "(GMT+11) Sydney" },
{ value: "Asia/Seoul", label: "(GMT+9) Seoul" },
],
},
] as const;
type Timezone = (typeof timezones)[number];
export default function TimezoneCombobox() {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState("America/New_York");
const contentId = React.useId();
const selectedGroup = React.useMemo(
() => timezones.find((group) => group.timezones.some((tz) => tz.value === value)),
[value],
);
const selectedTimezoneLabel = React.useMemo(() => {
if (!selectedGroup) return undefined;
for (const tz of selectedGroup.timezones) {
if (tz.value === value) {
return tz.label;
}
}
return undefined;
}, [value, selectedGroup]);
return (
<Popover open={open} onOpenChange={setOpen}>
{/* A button-backed select-only combobox follows the ARIA combobox pattern. */}
<PopoverTrigger
render={
<Button
variant="noShadow"
role="combobox"
aria-label="Select a timezone"
aria-expanded={open}
aria-controls={open ? contentId : undefined}
className="h-12 w-full justify-between px-2.5 md:max-w-[200px]"
/>
}
>
{selectedTimezoneLabel ? (
<div className="flex flex-col items-start gap-0.5">
<span className="text-main-foreground text-xs font-normal">{selectedGroup?.label}</span>
<span>{selectedTimezoneLabel}</span>
</div>
) : (
"Select timezone"
)}
<ChevronDownIcon />
</PopoverTrigger>
<PopoverContent
id={contentId}
aria-label="Choose a timezone"
className="p-0 border-0"
align="start"
>
<Command className="**:data-[slot=command-input-wrapper]:h-11">
<CommandInput placeholder="Search timezone..." />
<CommandList>
<CommandEmpty>No timezone found.</CommandEmpty>
{timezones.map((region) => (
<CommandGroup className="p-2" key={region.label} heading={region.label}>
{region.timezones.map((timezone) => (
<CommandItem
key={timezone.value}
value={timezone.value}
keywords={[timezone.label]}
onSelect={(currentValue) => {
setValue(currentValue as Timezone["timezones"][number]["value"]);
setOpen(false);
}}
>
{timezone.label}
<CheckIcon
className="ml-auto opacity-0 data-[selected=true]:opacity-100"
data-selected={value === timezone.value}
/>
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
#Multiselect
Select more than one framework, move to an unselected option, and search without losing the existing selections. The trigger summarizes the selection; reopening the popup preserves it.
"use client";
import { Combobox } from "@base-ui/react/combobox";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
const frameworks = ["Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro"];
export default function ComboboxWithCheckbox() {
return (
<Combobox.Root items={frameworks} multiple autoHighlight>
<Combobox.Trigger
aria-label="Select frameworks"
render={<Button variant="noShadow" className="w-full max-w-[300px] justify-between" />}
>
<span className="min-w-0 truncate">
<Combobox.Value>
{(values: string[]) => values.join(", ") || "Select frameworks (multi-select)..."}
</Combobox.Value>
</span>
<ChevronsUpDown aria-hidden="true" />
</Combobox.Trigger>
<Combobox.Portal>
<Combobox.Positioner align="start" sideOffset={4} className="isolate z-50">
<Combobox.Popup
data-slot="combobox-popup"
aria-label="Choose frameworks"
className="max-h-(--available-height) w-[300px] max-w-(--available-width) overflow-y-auto rounded-base border-2 border-border bg-main text-main-foreground outline-none"
>
<Combobox.Input
aria-label="Search frameworks"
placeholder="Search framework..."
className="h-11 w-full border-0 border-b-2 border-main-foreground bg-transparent px-3 text-sm font-base text-main-foreground outline-none placeholder:text-main-foreground"
/>
<Combobox.Empty className="py-6 text-center text-sm">
No framework found.
</Combobox.Empty>
<Combobox.List aria-label="Frameworks" className="grid gap-1 p-2 empty:p-0">
{(framework: string) => (
<Combobox.Item
key={framework}
value={framework}
className="relative flex cursor-default items-center gap-2 rounded-base px-2 py-2 text-sm font-base outline-none select-none data-highlighted:outline-2 data-highlighted:outline-solid data-highlighted:-outline-offset-4 data-highlighted:outline-current"
>
<span
data-slot="combobox-selection-mark"
aria-hidden="true"
className="grid size-5 shrink-0 place-content-center rounded-base border-2 border-current"
>
<Combobox.ItemIndicator>
<CheckIcon className="size-4" />
</Combobox.ItemIndicator>
</span>
{framework}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Portal>
</Combobox.Root>
);
}