Searchable select
One pick from a filterable list in a popover, with a check on the current value.
npx shadcn@latest add https://ui.goodtabs.co/r/searchable-select.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/searchable-select
Six frameworks with Next.js preselected. Type to filter, Enter to pick.
With descriptions
Deploy region
Each option carries one quiet line underneath. Controlled through value.
Disabled option and empty text
Enterprise cannot be chosen, and a custom line shows when nothing matches.
Usage
import { SearchableSelect } from "@/components/ui/searchable-select"
<SearchableSelect
options={[
{ value: "next", label: "Next.js" },
{ value: "astro", label: "Astro" },
]}
defaultValue="next"
placeholder="Pick a framework"
aria-label="Framework"
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| options | { value: string; label: string; description?: string; disabled?: boolean }[] | The choices, in order. | |
| value | string | Controlled value. | |
| defaultValue | string | Initial value when uncontrolled. | |
| onValueChange | (value: string) => void | Called with the new value. | |
| placeholder | string | "Select..." | Trigger text when nothing is chosen. |
| searchPlaceholder | string | "Search..." | Placeholder of the filter field. |
| emptyText | string | "No results" | Shown when the filter matches nothing. |
| width | string | Width of the trigger and the list, any CSS length. Fills its container by default. |
Source
searchable-select/searchable-select.tsx
"use client";
import * as React from "react";
import { ChevronDownIcon } from "lucide-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";
import { cn } from "@/lib/utils";
export type SearchableSelectOption = {
/** The value reported by onValueChange. */
value: string;
/** Text shown in the list and on the trigger once chosen. */
label: string;
/** One quiet line under the label. */
description?: string;
/** Shown but not choosable. */
disabled?: boolean;
};
type SearchableSelectProps = Omit<React.ComponentProps<typeof Button>, "value" | "defaultValue" | "onChange"> & {
/** The choices, in order. */
options: SearchableSelectOption[];
/** Controlled value. */
value?: string;
/** Initial value when uncontrolled. */
defaultValue?: string;
/** Called with the new value. */
onValueChange?: (value: string) => void;
/** Trigger text when nothing is chosen. */
placeholder?: string;
/** Placeholder of the filter field. */
searchPlaceholder?: string;
/** Shown when the filter matches nothing. */
emptyText?: string;
/** Width of the trigger and the list, any CSS length. Fills its container by default. */
width?: string;
};
/**
* One pick from a filterable list. An outline pill opens a popover with a search field;
* the list filters as you type and a check marks the current value.
*/
export function SearchableSelect({
options,
value: valueProp,
defaultValue,
onValueChange,
placeholder = "Select...",
searchPlaceholder = "Search...",
emptyText = "No results",
width,
className,
style,
disabled,
...props
}: SearchableSelectProps) {
const [open, setOpen] = React.useState(false);
const [inner, setInner] = React.useState(defaultValue ?? "");
const value = valueProp ?? inner;
const selected = options.find((o) => o.value === value);
const choose = (next: string) => {
if (valueProp === undefined) setInner(next);
onValueChange?.(next);
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
disabled={disabled}
data-slot="searchable-select"
data-placeholder={selected ? undefined : ""}
className={cn("w-full justify-between font-normal", className)}
style={{ width, ...style }}
{...props}
>
<span className={cn("truncate", !selected && "text-muted-foreground")}>
{selected ? selected.label : placeholder}
</span>
<ChevronDownIcon data-icon="inline-end" className="text-muted-foreground" aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-(--radix-popover-trigger-width) min-w-48 gap-0 rounded-md border p-0 ring-0"
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty className="text-muted-foreground">{emptyText}</CommandEmpty>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
keywords={[option.value]}
disabled={option.disabled}
data-checked={option.value === value}
onSelect={() => choose(option.value)}
className={cn("py-0", option.description ? "min-h-8 py-1.5" : "h-8")}
>
<span className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
{option.description && (
<span className="truncate text-xs text-foreground/70">{option.description}</span>
)}
</span>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
For AI agents
Hand your agent this page, the markdown at /components/searchable-select/prompt.md, or the Copy AI prompt button above.
When to use
- One choice from more than six or seven options, where typing beats scrolling
- Settings and forms: region, timezone, assignee, repository
- When each option needs a second line to tell it apart
When not to use
- Five options or fewer; the shadcn Select or a segmented control is lighter
- Several picks at once; use the multi select
- Free text with suggestions; this only accepts listed values
Agent notes
- Works controlled (value, onValueChange) or uncontrolled (defaultValue); it takes functions, so render it from a client component
- The trigger is shadcn's outline Button, so every Button prop applies; give it an aria-label or pair it with a visible label
- The list filters on label and value through cmdk; the popover matches the trigger width and closes on pick
- Disabled options stay visible at half opacity and cannot be picked