Combobox
A single-select combobox with inline search, async loading support, and full controlled/uncontrolled API
src/registry/base/combobox/components/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>
);
}
- Interaction
- dropdown
DependenciesBundle weight · light
- command
- cmdk
- icons
- lucide-react