Combobox
Autocomplete input and command palette with a list of suggestions.
Installation
Follow the Quickstart guide if you haven't already.
Install <Popover/>
, <Command/>
, and <Button/>
.
Copy and paste the following code into your project.
"use client";
import { createContext, useContext, useState } from "react";
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
import { cn } from "~/utils/tailwind";
import { Button } from "~/components/ui/Button";
import {
CommandEmpty,
CommandInput,
CommandGroup,
CommandItem,
Command,
CommandList,
} from "~/components/ui/Command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/Popover";
type ComboboxDisplayValue = {
label: string;
value: string;
};
type ComboboxContextProps = {
value: string | undefined;
setValue: (value: string | undefined) => void;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
items: ComboboxDisplayValue[];
};
const ComboboxContext = createContext<ComboboxContextProps>({
value: undefined,
setValue: () => {
return;
},
isOpen: false,
setIsOpen: () => {
return;
},
items: [],
});
interface ComboboxProps extends React.ComponentProps<typeof Popover> {
value?: string;
onChange?: (value: string | undefined) => void;
defaultValue?: string;
items?: ComboboxDisplayValue[];
}
function Combobox({
value: controlledValue,
onChange: setControlledValue,
defaultValue,
items = [],
...props
}: ComboboxProps) {
const [uncontrolledValue, setUncontrolledValue] = useState<
string | undefined
>(defaultValue);
const [isOpen, setIsOpen] = useState<boolean>(false);
const value = controlledValue ?? uncontrolledValue;
const setValue = setControlledValue ?? setUncontrolledValue;
return (
<ComboboxContext.Provider
value={{ value, setValue, isOpen, setIsOpen, items }}
>
<Popover open={isOpen} onOpenChange={setIsOpen} {...props} />
</ComboboxContext.Provider>
);
}
function ComboboxTrigger({
children,
className,
...props
}: Omit<React.ComponentProps<typeof Button>, "variant" | "size" | "subject">) {
const { isOpen } = useContext(ComboboxContext);
return (
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isOpen}
className={cn(
"w-48 items-center justify-between gap-x-2",
"[&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:opacity-50",
className,
)}
{...props}
>
<span className="truncate">{children}</span>
<ChevronsUpDownIcon />
</Button>
</PopoverTrigger>
);
}
interface ComboboxValueProps {
placeholder: string;
}
function ComboboxValue({ placeholder }: ComboboxValueProps) {
const { value, items } = useContext(ComboboxContext);
return value
? items.find((item) => item.value === value)?.label
: placeholder;
}
function ComboboxContent({
children,
className,
...props
}: React.ComponentProps<typeof PopoverContent>) {
return (
<PopoverContent {...props} className={cn("w-48 p-0", className)}>
<Command>{children}</Command>
</PopoverContent>
);
}
function ComboboxInput({
className,
...props
}: React.ComponentProps<typeof CommandInput>) {
return (
<CommandInput
className={cn("[&>[cmdk-input]]:h-10 [&>svg]:size-3.5", className)}
{...props}
/>
);
}
function ComboboxEmpty(props: React.ComponentProps<typeof CommandEmpty>) {
return <CommandEmpty {...props} />;
}
function ComboboxList(props: React.ComponentProps<typeof CommandList>) {
return <CommandList {...props} />;
}
function ComboboxGroup({
className,
...props
}: React.ComponentProps<typeof CommandGroup>) {
return <CommandGroup {...props} className={cn("p-1", className)} />;
}
interface ComboboxItemProps
extends Omit<React.ComponentProps<typeof CommandItem>, "onSelect"> {
value: string;
onSelect?: (value: string | undefined) => void;
}
function ComboboxItem({
value: itemValue,
onSelect,
className,
children,
...props
}: ComboboxItemProps) {
const { value, setValue, setIsOpen } = useContext(ComboboxContext);
return (
<CommandItem
value={itemValue}
onSelect={(currentValue) => {
const newValue = currentValue === value ? undefined : currentValue;
setValue(newValue);
onSelect?.(newValue);
setIsOpen(false);
}}
className={cn("rounded-1 text-3.5 h-8 pl-8 [&>svg]:size-3.5", className)}
{...props}
>
<CheckIcon
className={cn(value === itemValue ? "opacity-100" : "opacity-0")}
/>
<span>{children}</span>
</CommandItem>
);
}
export {
Combobox,
ComboboxTrigger,
ComboboxValue,
ComboboxContent,
ComboboxInput,
ComboboxList,
ComboboxEmpty,
ComboboxGroup,
ComboboxItem,
};
Update the import paths to match your project setup.
Usage
import {
Combobox,
ComboboxTrigger,
ComboboxContent,
ComboboxInput,
ComboboxEmpty,
ComboboxGroup,
ComboboxItem,
ComboboxList,
ComboboxValue,
} from "~/components/ui/Combobox";
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 ComboboxDemo() {
return (
<Combobox items={FRAMEWORKS}>
<ComboboxTrigger>
<ComboboxValue placeholder="Select framework..." />
</ComboboxTrigger>
<ComboboxContent>
<ComboboxInput placeholder="Search frameworks..." />
<ComboboxList>
<ComboboxEmpty>No framework found.</ComboboxEmpty>
<ComboboxGroup>
{FRAMEWORKS.map((framework) => (
<ComboboxItem value={framework.value} key={framework.value}>
{framework.label}
</ComboboxItem>
))}
</ComboboxGroup>
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}