Combobox
A single-select combobox with inline search, async loading support, and full controlled/uncontrolled API.
Selected: Paris
import { useState } from "react";import { Combobox } from "@/components/ui/combobox"";import type { SelectionOption } from "@/registry/base/use-filtered-options/hooks/use-filtered-options";import { Search, ThumbsUpIcon } from "lucide-react";
type City = | "nyc" | "london" | "tokyo" | "paris" | "sydney" | "dubai" | "berlin" | "singapore";
const cities: SelectionOption<City>[] = [ { value: "nyc", label: "New York" }, { value: "london", label: "London" }, { value: "tokyo", label: "Tokyo" }, { value: "paris", label: "Paris" }, { value: "sydney", label: "Sydney" }, { value: "dubai", label: "Dubai" }, { value: "berlin", label: "Berlin" }, { value: "singapore", label: "Singapore" },];
export function BasicCombobox() { const [selected, setSelected] = useState<SelectionOption<City> | null>( cities[3] || null, );
return ( <div className="flex w-72 flex-col gap-3"> <Combobox items={cities} selected={selected} onSelectedChange={setSelected} placeholder="Select a city..." startAddon={<Search />} endAddon={selected && <ThumbsUpIcon />} closeAfterSelect /> <p className="text-sm text-muted-foreground"> {selected ? ( <> Selected:{" "} <span className="font-medium text-foreground"> {selected.label} </span> </> ) : ( "No city selected." )} </p> </div> );}Installation
Section titled “Installation”This component relies on other items which must be installed first
Install the following dependencies
Copy and paste the following code into your project.
components/ui/combobox.tsx
import { useCallback, type ComponentProps, type ReactNode } from "react";import { Command as CommandPrimitive } from "cmdk";import { Check } from "lucide-react";
import { Popover, PopoverAnchor, PopoverContent,} from "@/components/ui/popover";import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList,} from "@/components/ui/command";import { Skeleton } from "@/components/ui/skeleton";import { cn } from "@/lib/utils";
import { useControlledState } from "@/hooks/use-controlled-state";import { useFilteredOptions, type SelectionOption,} from "@/hooks/use-filtered-options";import { useOptionMap } from "@/hooks/use-option-map";import { InputGroup, InputGroupAddon, InputGroupInput,} from "@/components/ui/input-group";
type ComboboxProps<T extends string> = { /** The currently selected option, or `null` when nothing is selected. */ selected: SelectionOption<T> | null; /** Called when the selection changes. Receives `null` when the active item is deselected. */ onSelectedChange: (value: SelectionOption<T> | null) => void;
/** Full list of options to display. */ items: SelectionOption<T>[]; /** * Custom renderer for each list item. * Receives the option and a boolean indicating whether it is currently selected. * When omitted a default label + checkmark layout is used. */ renderOption?: (option: SelectionOption<T>, selected: boolean) => ReactNode;
/** Controlled search query. Omit to let the component manage query state internally. */ query?: string; /** Called whenever the search query changes. */ onQueryChange?: (query: string) => void;
/** Controlled open state of the dropdown. Omit to let the component manage it internally. */ open?: boolean; /** Called whenever the open state changes. */ onOpenChange?: (open: boolean) => void;
/** * Skip local filtering entirely. * Use when `items` are already filtered server-side for the current query. */ disableLocalFilter?: boolean; /** * Custom filter predicate. Receives each option and the current query string. * Replaces the default case-insensitive label match when provided. */ filterFn?: (item: SelectionOption<T>, query: string) => boolean;
/** Placeholder shown in the search input when empty. @default "Search..." */ placeholder?: string; /** Content shown when no options match the current query. @default "No results found." */ emptyContent?: ReactNode; /** * Content shown in place of the option list while `isLoading` is `true`. * Defaults to a single skeleton bar. */ loadingContent?: ReactNode;
/** Render the loading skeleton in place of the option list. @default false */ isLoading?: boolean; /** Close the dropdown immediately after an option is selected. @default false */ closeAfterSelect?: boolean;
/** Content rendered as a leading addon inside the input (e.g. a search icon or label). */ startAddon?: ReactNode; /** Content rendered as a trailing addon inside the input (e.g. a clear button or badge). */ endAddon?: ReactNode;} & ComponentProps<typeof PopoverContent>;
/** * Single-select combobox with an inline search input and a dropdown option list. * * **Required:** `selected`, `onSelectedChange`, `items` * * Supports controlled `open` and `query` state, async loading, custom option rendering, * and custom filtering. Embed icons or actions inside the input via `startAddon` / `endAddon`. */export function Combobox<T extends string>({ selected, onSelectedChange, items, renderOption, query: queryProp, onQueryChange, open: openProp, onOpenChange, disableLocalFilter, filterFn, placeholder = "Search...", emptyContent = "No results found.", loadingContent, isLoading = false, closeAfterSelect = false, startAddon, endAddon, ...props}: ComboboxProps<T>) { const [query, setQuery] = useControlledState({ value: queryProp, defaultValue: "", onChange: onQueryChange, });
const [open, setOpen] = useControlledState({ value: openProp, defaultValue: false, onChange: onOpenChange, });
const optionMap = useOptionMap(items); const filteredItems = useFilteredOptions({ items, query, filterFn, disableLocalFilter, });
const handleSelect = useCallback( (selectedValue: string) => { const option = optionMap.get(selectedValue as T);
if (!option) { return; }
if (selected?.value === option.value) { onSelectedChange(null); setQuery(""); } else { onSelectedChange(option); setQuery(option.label);
if (closeAfterSelect) { setOpen(false); } } }, [ optionMap, selected?.value, onSelectedChange, setQuery, closeAfterSelect, setOpen, ], );
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => { if (e.relatedTarget?.hasAttribute("cmdk-list")) { return; } if (selected) { setQuery(selected.label); } };
return ( <div data-slot="combobox-content" className="relative w-full"> <Popover open={open} onOpenChange={setOpen}> <Command shouldFilter={false}> <PopoverAnchor asChild> <InputGroup> {startAddon && ( <InputGroupAddon align="inline-start"> {startAddon} </InputGroupAddon> )} <CommandPrimitive.Input asChild value={query} onValueChange={setQuery} onMouseDown={() => setOpen(true)} onKeyDown={(e) => { if (e.key === "Escape") { if (open) { setOpen(false); } else { setQuery(""); } return; } setOpen(true); }} onBlur={handleBlur} > <InputGroupInput placeholder={placeholder} onFocus={() => setOpen(true)} /> </CommandPrimitive.Input>
{endAddon && ( <InputGroupAddon align="inline-end">{endAddon}</InputGroupAddon> )} </InputGroup> </PopoverAnchor>
{!open && <CommandList aria-hidden="true" className="hidden" />}
<PopoverContent asChild className="w-(--radix-popover-trigger-width) p-0" onInteractOutside={(e) => { if ( e.target instanceof Element && e.target.hasAttribute("cmdk-input") ) { e.preventDefault(); } }} {...props} > <CommandList> {isLoading ? ( <CommandPrimitive.Loading> <div className="p-2"> {loadingContent ?? <Skeleton className="h-6 w-full" />} </div> </CommandPrimitive.Loading> ) : filteredItems.length > 0 ? ( <CommandGroup> {filteredItems.map((item) => ( <CommandItem key={item.value} value={item.value} onSelect={handleSelect} onMouseDown={(e) => e.preventDefault()} > {renderOption ? ( renderOption(item, selected?.value === item.value) ) : ( <> <Check className={cn( "mr-2 h-4 w-4", selected?.value === item.value ? "opacity-100" : "opacity-0", )} /> {item.label} </> )} </CommandItem> ))} </CommandGroup> ) : ( <CommandEmpty>{emptyContent}</CommandEmpty> )} </CommandList> </PopoverContent> </Command> </Popover> </div> );}hooks/use-option-map.ts
import { useMemo } from "react";import type { SelectionOption } from "@/hooks/use-filtered-options";
/** Memoised `Map<value, SelectionOption>` for O(1) option lookup after a cmdk selection. */export function useOptionMap<T extends string>(items: SelectionOption<T>[]) { return useMemo( () => new Map(items.map((item) => [item.value, item])), [items], );}hooks/use-filtered-options.ts
import { useMemo } from "react";
export type SelectionOption<T extends string> = { /** Unique identifier for this option, used as the cmdk `value`. */ value: T; /** Display string shown in the list and the search input. */ label: string; /** When `true`, the item renders but cannot be selected. @default false */ disabled?: boolean;};
type UseFilteredOptionsProps<T extends string> = { items: SelectionOption<T>[]; query: string; disableLocalFilter?: boolean; filterFn?: (item: SelectionOption<T>, query: string) => boolean;};
/** * Filters a `SelectionOption` list against a search query. * * Returns the full list when `disableLocalFilter` is `true` or `query` is blank. * Applies `filterFn` when provided; otherwise uses a case-insensitive substring * match on `label`. */export function useFilteredOptions<T extends string>({ items, query, disableLocalFilter = false, filterFn,}: UseFilteredOptionsProps<T>) { return useMemo(() => { if (disableLocalFilter || !query.trim()) { return items; } if (filterFn) { return items.filter((item) => filterFn(item, query)); } const normalizedQuery = query.toLowerCase(); return items.filter((item) => item.label.toLowerCase().includes(normalizedQuery), ); }, [items, query, disableLocalFilter, filterFn]);}Update the import paths to match your project setup.
import { Combobox } from "@/components/ui/combobox";import type { SelectionOption } from "@/hooks/use-filtered-options";const options: SelectionOption<string>[] = [ { value: "react", label: "React" }, { value: "vue", label: "Vue" },];
<Combobox items={options} selected={selected} onSelectedChange={setSelected} placeholder="Search..." closeAfterSelect/>;Each option is { value: T, label: string, disabled?: boolean }. The generic T extends string so you can keep values fully typed.
Examples
Section titled “Examples”Input Addons
Section titled “Input Addons”Use startAddon and endAddon to embed any element inside the input — a search icon, a clear button, a status indicator. The slots are rendered inside InputGroup so they align and interact correctly with the text field.
import { useState } from "react";import { Search, X } from "lucide-react";import { Combobox } from "@/components/ui/combobox"";import type { SelectionOption } from "@/registry/base/use-filtered-options/hooks/use-filtered-options";
type Framework = | "react" | "vue" | "angular" | "svelte" | "solid" | "nextjs" | "nuxt" | "astro";
const frameworks: SelectionOption<Framework>[] = [ { value: "react", label: "React" }, { value: "vue", label: "Vue" }, { value: "angular", label: "Angular" }, { value: "svelte", label: "Svelte" }, { value: "solid", label: "Solid" }, { value: "nextjs", label: "Next.js" }, { value: "nuxt", label: "Nuxt" }, { value: "astro", label: "Astro" },];
export function ComboboxWithAddons() { const [selected, setSelected] = useState<SelectionOption<Framework> | null>( null, ); const [query, setQuery] = useState<string>("");
return ( <div className="w-72"> <Combobox items={frameworks} selected={selected} onSelectedChange={setSelected} placeholder="Search framework..." closeAfterSelect query={query} onQueryChange={setQuery} startAddon={<Search className="size-3.5 text-muted-foreground" />} endAddon={ selected && ( <button onMouseDown={(e) => { e.preventDefault(); setSelected(null); setQuery(""); }} className="flex items-center justify-center rounded p-0.5 hover:bg-muted" > <X className="size-3 text-muted-foreground" /> </button> ) } /> </div> );}Async Search
Section titled “Async Search”Wire onQueryChange to a debounced fetch, set disableLocalFilter so the component shows whatever you pass in items, and toggle isLoading while the request is in flight. The skeleton appears in place of the list until results arrive.
Unassigned
import { useState } from "react";import { Combobox } from "@/components/ui/combobox"";import { useDebounce } from "@/registry/base/use-debounce/hooks/use-debounce";import type { SelectionOption } from "@/registry/base/use-filtered-options/hooks/use-filtered-options";import { Loader2 } from "lucide-react";
type UserId = string;
const allUsers: SelectionOption<UserId>[] = [ { value: "u1", label: "Alice Johnson" }, { value: "u2", label: "Bob Smith" }, { value: "u3", label: "Carol White" }, { value: "u4", label: "David Brown" }, { value: "u5", label: "Eve Davis" }, { value: "u6", label: "Frank Miller" }, { value: "u7", label: "Grace Wilson" }, { value: "u8", label: "Henry Moore" },];
function searchUsers(query: string): Promise<SelectionOption<UserId>[]> { return new Promise((resolve) => setTimeout(() => { const q = query.toLowerCase(); resolve(allUsers.filter((u) => u.label.toLowerCase().includes(q))); }, 600), );}
export function AsyncSearchCombobox() { const [selected, setSelected] = useState<SelectionOption<UserId> | null>( null, ); const [items, setItems] = useState(allUsers); const [isLoading, setIsLoading] = useState(false);
const { execute: onQueryChange } = useDebounce(async (query: string) => { if (!query.trim()) { setItems(allUsers); setIsLoading(false); return; } setIsLoading(true); const results = await searchUsers(query); setItems(results); setIsLoading(false); }, 200);
return ( <div className="flex w-72 flex-col gap-3"> <Combobox items={items} selected={selected} onSelectedChange={setSelected} onQueryChange={onQueryChange} disableLocalFilter isLoading={isLoading} placeholder="Assign to..." emptyContent="No users found." loadingContent={<Loader2 className="animate-spin w-full" />} closeAfterSelect /> <p className="text-sm text-muted-foreground"> {selected ? ( <> Assigned to:{" "} <span className="font-medium text-foreground"> {selected.label} </span> </> ) : ( "Unassigned" )} </p> </div> );}Custom Option Rendering
Section titled “Custom Option Rendering”Pass renderOption to take full control of how each item looks. Receives the option object and a boolean for selected state — use them to show icons, badges, or secondary text.
import { useState } from "react";import { Check } from "lucide-react";import { cn } from "@/lib/utils";import { Combobox } from "@/components/ui/combobox"";import type { SelectionOption } from "@/registry/base/use-filtered-options/hooks/use-filtered-options";
type LangId = "ts" | "py" | "go" | "rust" | "java" | "csharp" | "cpp" | "swift";
type LangOption = SelectionOption<LangId> & { icon: string; badge: string };
const languages: LangOption[] = [ { value: "ts", label: "TypeScript", icon: "🟦", badge: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300", }, { value: "py", label: "Python", icon: "🐍", badge: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950 dark:text-yellow-300", }, { value: "go", label: "Go", icon: "🔵", badge: "bg-cyan-100 text-cyan-700 dark:bg-cyan-950 dark:text-cyan-300", }, { value: "rust", label: "Rust", icon: "🦀", badge: "bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-300", }, { value: "java", label: "Java", icon: "☕", badge: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300", }, { value: "csharp", label: "C#", icon: "🟣", badge: "bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300", }, { value: "cpp", label: "C++", icon: "⚙️", badge: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300", }, { value: "swift", label: "Swift", icon: "🧡", badge: "bg-orange-100 text-orange-600 dark:bg-orange-950 dark:text-orange-300", },];
const langMap = new Map(languages.map((l) => [l.value, l]));
export function CustomRenderCombobox() { const [selected, setSelected] = useState<SelectionOption<LangId> | null>( null, );
const activeLang = selected ? langMap.get(selected.value) : null;
return ( <div className="flex w-72 flex-col gap-3"> <Combobox items={languages} selected={selected} onSelectedChange={setSelected} placeholder="Pick a language..." closeAfterSelect renderOption={(option, isSelected) => { const lang = langMap.get(option.value)!; return ( <div className="flex w-full items-center gap-2"> <span className="text-base leading-none">{lang.icon}</span> <span className={cn("flex-1 text-sm", isSelected && "font-medium")} > {lang.label} </span> {isSelected && <Check className="ml-auto size-3.5" />} </div> ); }} /> {activeLang && ( <span className={cn( "inline-flex w-fit items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium", activeLang.badge, )} > {activeLang.icon} {activeLang.label} </span> )} </div> );}Create New Option
Section titled “Create New Option”Use emptyContent with a controlled query to show an inline “Add” action when no results match. onMouseDown on the button prevents the input from blurring (which would close the dropdown before the click fires).
No city selected.
import { useState } from "react";import { PlusCircle } from "lucide-react";
import { Combobox } from "@/components/ui/combobox"";import type { SelectionOption } from "@/registry/base/use-filtered-options/hooks/use-filtered-options";
const initialCities: SelectionOption<string>[] = [ { value: "nyc", label: "New York" }, { value: "london", label: "London" }, { value: "tokyo", label: "Tokyo" }, { value: "paris", label: "Paris" }, { value: "sydney", label: "Sydney" },];
export function CreatableCombobox() { const [cities, setCities] = useState(initialCities); const [selected, setSelected] = useState<SelectionOption<string> | null>( null, ); const [query, setQuery] = useState("");
const trimmed = query.trim(); const showAdd = trimmed.length > 0 && !cities.some((c) => c.label.toLowerCase().includes(trimmed.toLowerCase()));
const handleAdd = () => { const label = trimmed; const value = label.toLowerCase().replace(/\s+/g, "-"); const newCity: SelectionOption<string> = { value, label }; setCities((prev) => [...prev, newCity]); setSelected(newCity); setQuery(newCity.label); };
return ( <div className="flex w-72 flex-col gap-3"> <Combobox items={cities} selected={selected} onSelectedChange={setSelected} query={query} onQueryChange={setQuery} placeholder="Search or add a city..." closeAfterSelect emptyContent={ showAdd ? ( <button className="inline-flex cursor-pointer items-center gap-1.5 font-medium text-foreground hover:underline" onMouseDown={(e) => e.preventDefault()} onClick={handleAdd} > <PlusCircle className="size-3.5" /> {`Add "${trimmed}"`} </button> ) : undefined } /> <p className="text-sm text-muted-foreground"> {selected ? ( <> Selected:{" "} <span className="font-medium text-foreground"> {selected.label} </span> </> ) : ( "No city selected." )} </p> </div> );}| Prop | Type | Default | Description |
|---|---|---|---|
selected | SelectionOption<T> | null | — | Required. The currently selected option, or null |
onSelectedChange | (value: SelectionOption<T> | null) => void | — | Required. Called when selection changes; receives null on deselect |
items | SelectionOption<T>[] | — | Required. Full list of options to display |
open | boolean | — | Controlled open state. Omit to let the component manage it |
onOpenChange | (open: boolean) => void | — | Called whenever the dropdown opens or closes |
query | string | — | Controlled search query. Omit for uncontrolled |
onQueryChange | (query: string) => void | — | Called whenever the search input changes |
placeholder | string | "Search..." | Placeholder text shown in the search input |
closeAfterSelect | boolean | false | Close the dropdown immediately after an option is selected |
disableLocalFilter | boolean | — | Skip client-side filtering. Use when items are already filtered server-side |
filterFn | (item: SelectionOption<T>, query: string) => boolean | — | Custom filter predicate. Replaces the default case-insensitive label match |
isLoading | boolean | false | Show the loading state in place of the option list |
emptyContent | ReactNode | "No results found." | Content shown when no options match the query |
loadingContent | ReactNode | Skeleton bar | Content shown in place of the list while isLoading is true |
renderOption | (option: SelectionOption<T>, selected: boolean) => ReactNode | — | Custom renderer for each list item |
startAddon | ReactNode | — | Leading addon rendered inside the input (e.g. a search icon) |
endAddon | ReactNode | — | Trailing addon rendered inside the input (e.g. a clear button) |
All PopoverContent props (align, side, sideOffset, etc.) are also accepted and forwarded to the dropdown.