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, useEffect, useMemo, useState, type ComponentProps, type ReactNode,} from "react";import { Command as CommandPrimitive } from "cmdk";import { Check, X } 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 type { SelectionOption } from "@/hooks/use-filtered-options";import { useOptionMap } from "@/hooks/use-option-map";import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput,} from "@/components/ui/input-group";
export type ComboboxGroup<T extends string> = { /** Optional heading rendered above this group of options. */ label?: string; items: SelectionOption<T>[];};
const FOOTER_ITEM_VALUE = "__kaui_combobox_footer__";
function isGrouped<T extends string>( items: SelectionOption<T>[] | ComboboxGroup<T>[],): items is ComboboxGroup<T>[] { return items.length > 0 && "items" in (items[0] as object);}
type ComboboxProps<T extends string> = { /** Currently selected option, or `null` when nothing is selected. */ selected: SelectionOption<T> | null; /** Called when the selection changes. Receives `null` on deselect. */ onSelectedChange: (value: SelectionOption<T> | null) => void;
/** * Options to display. Accepts a flat `SelectionOption<T>[]` or a * `ComboboxGroup<T>[]` to render items under labeled headings. */ items: SelectionOption<T>[] | ComboboxGroup<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 client-side filtering. Use when items are already filtered server-side. */ disableLocalFilter?: boolean; /** Custom filter predicate. Replaces the default case-insensitive label match. */ 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 skeleton bar. */ loadingContent?: ReactNode;
/** Show the loading state in place of the option list. @default false */ isLoading?: boolean; /** Close the dropdown immediately after an option is selected. @default false */ closeAfterSelect?: boolean; /** Disable the entire combobox. @default false */ disabled?: boolean; /** Show a built-in clear button when an option is selected. @default false */ clearable?: 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 status indicator). */ endAddon?: ReactNode; /** * A persistent CommandItem pinned to the bottom of the list. * Fully keyboard-navigable (arrow keys + Enter) unlike emptyContent. * Shown whenever the dropdown is open, regardless of whether items exist. */ footerItem?: { label: ReactNode; onSelect: () => void };} & 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, grouped options, * custom option rendering, and custom filtering. */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, disabled = false, clearable = false, startAddon, endAddon, footerItem, ...props}: ComboboxProps<T>) { const [query, setQuery] = useControlledState({ value: queryProp, defaultValue: "", onChange: onQueryChange, });
const [open, setOpen] = useControlledState({ value: openProp, defaultValue: false, onChange: onOpenChange, });
// Controlled highlighted item — starts empty so the first item is not // auto-focused when the dropdown opens. Resets on close so the next open // is also clean. const [highlightedValue, setHighlightedValue] = useState(""); useEffect(() => { if (!open) setHighlightedValue(""); }, [open]);
const groups = useMemo( (): ComboboxGroup<T>[] => !items.length ? [] : isGrouped(items) ? items : [{ items: items as SelectionOption<T>[] }], [items], );
const allOptions = useMemo(() => groups.flatMap((g) => g.items), [groups]); const optionMap = useOptionMap(allOptions);
const filteredGroups = useMemo((): ComboboxGroup<T>[] => { if (disableLocalFilter || !query.trim()) return groups; const pred = filterFn ?? ((item: SelectionOption<T>, q: string) => item.label.toLowerCase().includes(q.toLowerCase())); return groups .map((g) => ({ ...g, items: g.items.filter((item) => pred(item, query)), })) .filter((g) => g.items.length > 0); }, [groups, query, disableLocalFilter, filterFn]);
const hasItems = filteredGroups.length > 0;
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 handleClear = useCallback(() => { onSelectedChange(null); setQuery(""); }, [onSelectedChange, setQuery]);
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => { if (e.relatedTarget?.hasAttribute("cmdk-list")) return; if (selected) setQuery(selected.label); };
const renderItem = (item: SelectionOption<T>) => ( <CommandItem key={item.value} value={item.value} disabled={item.disabled} 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> );
const footerCommandItem = footerItem ? ( <CommandItem value={FOOTER_ITEM_VALUE} onSelect={footerItem.onSelect} onMouseDown={(e) => e.preventDefault()} > {footerItem.label} </CommandItem> ) : null;
return ( <div data-slot="combobox-content" className="relative w-full"> <Popover open={open} onOpenChange={setOpen}> <Command shouldFilter={false} value={highlightedValue} onValueChange={setHighlightedValue} > <PopoverAnchor asChild> <InputGroup> {startAddon && ( <InputGroupAddon align="inline-start"> {startAddon} </InputGroupAddon> )} <CommandPrimitive.Input asChild value={query} onValueChange={setQuery} onMouseDown={() => !disabled && setOpen(true)} onKeyDown={(e) => { if (disabled) return; if (e.key === "Escape") { if (open) { setOpen(false); } else { setQuery(""); } return; } setOpen(true); }} onBlur={handleBlur} > <InputGroupInput placeholder={placeholder} disabled={disabled} onFocus={() => !disabled && setOpen(true)} /> </CommandPrimitive.Input>
{((clearable && !!selected) || !!endAddon) && ( <InputGroupAddon align="inline-end"> {clearable && selected && ( <InputGroupButton size="icon-xs" onMouseDown={(e) => { e.preventDefault(); handleClear(); }} > <X className="size-3" /> </InputGroupButton> )} {endAddon} </InputGroupAddon> )} </InputGroup> </PopoverAnchor>
{!open && <CommandList aria-hidden="true" className="hidden" />}
<PopoverContent asChild className="w-(--radix-popover-trigger-width) p-0" onOpenAutoFocus={(e) => e.preventDefault()} 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> ) : hasItems ? ( <> {filteredGroups.map((group, index) => ( <CommandGroup key={index} heading={group.label}> {group.items.map(renderItem)} </CommandGroup> ))} {footerCommandItem && ( <CommandGroup>{footerCommandItem}</CommandGroup> )} </> ) : footerItem ? ( <> <p className="py-6 text-center text-sm">{emptyContent}</p> <CommandGroup>{footerCommandItem}</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> );}Grouped Options
Section titled “Grouped Options”Pass a ComboboxGroup<T>[] to items to render options under labeled headings. Groups filter independently — a group is removed entirely if none of its items match the query. The flat SelectionOption<T>[] form still works as before.
Unassigned
import { useState } from "react";import { Combobox, type ComboboxGroup,} from "@/components/ui/combobox"";import type { SelectionOption } from "@/registry/base/use-filtered-options/hooks/use-filtered-options";
type MemberId = string;
const groups: ComboboxGroup<MemberId>[] = [ { label: "My Team", items: [ { value: "alice", label: "Alice Johnson" }, { value: "bob", label: "Bob Smith" }, { value: "carol", label: "Carol White" }, ], }, { label: "Other Teams", items: [ { value: "david", label: "David Brown" }, { value: "eve", label: "Eve Davis" }, { value: "frank", label: "Frank Miller" }, { value: "grace", label: "Grace Wilson" }, ], },];
export function GroupedCombobox() { const [selected, setSelected] = useState<SelectionOption<MemberId> | null>( null, );
return ( <div className="flex w-72 flex-col gap-3"> <Combobox items={groups} selected={selected} onSelectedChange={setSelected} placeholder="Assign to..." clearable closeAfterSelect /> <p className="text-sm text-muted-foreground"> {selected ? ( <> Assigned to:{" "} <span className="font-medium text-foreground"> {selected.label} </span> </> ) : ( "Unassigned" )} </p> </div> );}Footer Item
Section titled “Footer Item”Pass footerItem to pin a persistent action at the bottom of the list. Unlike emptyContent, it is fully keyboard-navigable and always visible while the dropdown is open — regardless of whether results exist.
The canonical use case is a server-search field where the user can either pick a suggestion or submit the raw typed phrase as a full-text search. Conditionally returning undefined when the query is empty keeps the item hidden until the user has typed something.
Nothing selected
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, SearchIcon } from "lucide-react";
type ProductId = string;
const allProducts: SelectionOption<ProductId>[] = [ { value: "p1", label: "MacBook Pro 14" }, { value: "p2", label: "MacBook Air M3" }, { value: "p3", label: "iPad Pro 12.9" }, { value: "p4", label: "iPhone 15 Pro" }, { value: "p5", label: "Apple Watch Ultra" }, { value: "p6", label: "AirPods Pro" }, { value: "p7", label: "Mac Studio" }, { value: "p8", label: "Mac Mini M4" },];
function searchProducts(query: string): Promise<SelectionOption<ProductId>[]> { return new Promise((resolve) => setTimeout(() => { const q = query.toLowerCase(); resolve(allProducts.filter((p) => p.label.toLowerCase().includes(q))); }, 500), );}
export function FooterItemCombobox() { const [selected, setSelected] = useState<SelectionOption<ProductId> | null>( null, ); const [query, setQuery] = useState(""); const [items, setItems] = useState(allProducts); const [isLoading, setIsLoading] = useState(false); const [searchedPhrase, setSearchedPhrase] = useState<string | null>(null);
const { execute: onQueryChange } = useDebounce(async (q: string) => { setQuery(q); if (!q.trim()) { setItems(allProducts); setIsLoading(false); return; } setIsLoading(true); const results = await searchProducts(q); setItems(results); setIsLoading(false); }, 200);
return ( <div className="flex w-72 flex-col gap-3"> <Combobox items={items} selected={selected} onSelectedChange={(option) => { setSelected(option); setSearchedPhrase(null); }} onQueryChange={onQueryChange} disableLocalFilter isLoading={isLoading} placeholder="Search products..." emptyContent="No products found." loadingContent={<Loader2 className="w-full animate-spin" />} closeAfterSelect footerItem={ query.trim() ? { label: ( <span className="flex items-center gap-2 text-muted-foreground"> <SearchIcon className="size-3.5 shrink-0" /> Search for “{query}” </span> ), onSelect: () => { setSelected(null); setSearchedPhrase(query); }, } : undefined } />
<p className="text-sm text-muted-foreground"> {selected ? ( <> Selected:{" "} <span className="font-medium text-foreground"> {selected.label} </span> </> ) : searchedPhrase ? ( <> Searching for:{" "} <span className="font-medium text-foreground"> “{searchedPhrase}” </span> </> ) : ( "Nothing 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>[] \| ComboboxGroup<T>[] | — | Required. Flat list of options, or grouped via ComboboxGroup<T> (\{ label?: string; items: SelectionOption<T>[] \}) |
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) |
disabled | boolean | false | Disable the entire combobox — input is non-interactive and the dropdown cannot open |
clearable | boolean | false | Show a built-in × button when an option is selected. Clears both the selection and the query. |
endAddon | ReactNode | — | Trailing addon rendered inside the input (e.g. a status indicator). Rendered alongside the clearable button when both are present. |
footerItem | \{ label: ReactNode; onSelect: () => void \} | — | A persistent action pinned to the bottom of the list. Keyboard-navigable (arrow keys + Enter). Shown whenever the dropdown is open. Pass undefined conditionally to hide it. |
All PopoverContent props (align, side, sideOffset, etc.) are also accepted and forwarded to the dropdown.