import { XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxClear,
ComboboxContent,
ComboboxEmpty,
ComboboxIcon,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface Framework {
value: string;
label: string;
}
const frameworks: Framework[] = [
{
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 function ComboboxDemo() {
return (
<Combobox items={frameworks} itemToStringLabel={(item) => item.label}>
<ComboboxInput
render={(props) => (
<Label className="flex w-full max-w-[229px] flex-col gap-2">
Search framework
<div className="relative">
<Input placeholder="e.g. apple" {...props} />
<div className="absolute top-0 right-2 flex h-full items-center gap-1">
<ComboboxClear
render={
<Button
size="icon"
variant="ghost"
className="[&]:size-5"
>
<XIcon className="size-3.5" />
</Button>
}
/>
<ComboboxTrigger>
<ComboboxIcon />
</ComboboxTrigger>
</div>
</div>
</Label>
)}
/>
<ComboboxContent sideOffset={6}>
<ComboboxEmpty>No framework found.</ComboboxEmpty>
<ComboboxList>
{(item: Framework) => (
<ComboboxItem key={item.value} value={item}>
{item.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
Installation
npx shadcn@latest add @eo-n/combobox
Usage
Import all parts and piece them together.
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@components/ui/combobox";
interface Fruit {
id: string;
value: string;
}
const fruits = [
{ id: "a", value: "Apple" },
{ id: "b", value: "Banana" },
{ id: "c", value: "Cherry" },
{ id: "d", value: "Durian" },
];
<Combobox items={fruits}>
<ComboboxInput />
<ComboboxContent>
<ComboboxEmpty>No fruits found.</ComboboxEmpty>
<ComboboxList>
{(fruit: Fruit) => (
<ComboboxItem key={fruit.id} value={fruit}>
{fruit.value}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>;
Examples
Multi Select
import * as React from "react";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxValue,
} from "@/components/ui/combobox";
import { Label } from "@/components/ui/label";
interface Language {
value: string;
label: string;
}
const languages: Language[] = [
{
value: "javascript",
label: "JavaScript",
},
{
value: "typescript",
label: "TypeScript",
},
{
value: "python",
label: "Python",
},
{
value: "java",
label: "Java",
},
{
value: "csharp",
label: "C#",
},
{
value: "cpp",
label: "C++",
},
{
value: "go",
label: "Go",
},
{
value: "rust",
label: "Rust",
},
{
value: "php",
label: "PHP",
},
{
value: "ruby",
label: "Ruby",
},
{
value: "swift",
label: "Swift",
},
{
value: "kotlin",
label: "Kotlin",
},
{
value: "scala",
label: "Scala",
},
{
value: "dart",
label: "Dart",
},
{
value: "r",
label: "R",
},
];
export function ComboboxMultiSelect() {
const containerRef = React.useRef<HTMLDivElement | null>(null);
return (
<Combobox items={languages} multiple>
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="language">Programming languages</Label>
<ComboboxChips ref={containerRef}>
<ComboboxValue>
{(value: Language[]) => (
<React.Fragment>
{value.map((language) => (
<ComboboxChip
key={language.value}
aria-label={language.label}
>
{language.label}
</ComboboxChip>
))}
<ComboboxInput
id="language"
placeholder={value.length > 0 ? "" : "e.g. TypeScript"}
/>
</React.Fragment>
)}
</ComboboxValue>
</ComboboxChips>
</div>
<ComboboxContent anchor={containerRef} sideOffset={6}>
<ComboboxEmpty>No language found.</ComboboxEmpty>
<ComboboxList>
{(item: Language) => (
<ComboboxItem key={item.value} value={item}>
{item.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
Input Inside Popup
import * as React from "react";
import {
CheckCircle,
ChevronsUpDown,
Circle,
Clock,
Loader,
Search,
XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxIcon,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
ComboboxValue,
} from "@/components/ui/combobox";
interface Status {
value: string | null;
label: string;
icon: React.ReactNode;
}
const statuses: Status[] = [
{
value: null,
label: "Set status",
icon: <Circle />,
},
{
value: "pending",
label: "Pending",
icon: <Clock />,
},
{
value: "in_progress",
label: "In Progress",
icon: <Loader />,
},
{
value: "completed",
label: "Completed",
icon: <CheckCircle />,
},
{
value: "cancelled",
label: "Cancelled",
icon: <XCircle />,
},
];
export function ComboboxPopup() {
return (
<Combobox items={statuses} defaultValue={statuses[0]}>
<ComboboxTrigger
render={(props) => (
<Button
variant="outline"
className="w-[200px] justify-between"
{...props}
>
<ComboboxValue>
{(value: Status) => (
<span className="inline-flex items-center gap-2">
{value.icon}
{value.label}
</span>
)}
</ComboboxValue>
<ComboboxIcon render={<ChevronsUpDown />} />
</Button>
)}
/>
<ComboboxContent>
<div className="relative">
<div className="text-muted-foreground pointer-events-none absolute inset-y-0 left-0 flex items-center justify-center pl-3">
<Search className="size-4" />
</div>
<ComboboxInput placeholder="Change status..." className="pl-9" />
</div>
<ComboboxEmpty>No status found.</ComboboxEmpty>
<ComboboxList>
{(item: Status) => (
<ComboboxItem key={item.value} value={item}>
{item.icon}
{item.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
Group
import * as React from "react";
import { ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxGroupLabel,
ComboboxIcon,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
ComboboxValue,
} from "@/components/ui/combobox";
interface Label {
value: string;
label: string;
group: "Type" | "Priority" | "Status" | "Area";
}
interface LabelGroup {
value: string;
items: Label[];
}
const labelGroups: Label[] = [
{ value: "t-feature", label: "feature", group: "Type" },
{ value: "t-bug", label: "bug", group: "Type" },
{ value: "t-docs", label: "documentation", group: "Type" },
{ value: "t-refactor", label: "refactor", group: "Type" },
{ value: "t-test", label: "test", group: "Type" },
{ value: "t-chore", label: "chore", group: "Type" },
{ value: "p-low", label: "low", group: "Priority" },
{ value: "p-medium", label: "medium", group: "Priority" },
{ value: "p-high", label: "high", group: "Priority" },
{ value: "p-critical", label: "critical", group: "Priority" },
{ value: "s-open", label: "open", group: "Status" },
{ value: "s-in-progress", label: "in progress", group: "Status" },
{ value: "s-review", label: "in review", group: "Status" },
{ value: "s-closed", label: "closed", group: "Status" },
{ value: "a-frontend", label: "frontend", group: "Area" },
{ value: "a-backend", label: "backend", group: "Area" },
{ value: "a-api", label: "api", group: "Area" },
{ value: "a-infra", label: "infrastructure", group: "Area" },
{ value: "a-mobile", label: "mobile", group: "Area" },
];
function groupLabels(labels: Label[]): LabelGroup[] {
const groups: { [key: string]: Label[] } = {};
labels.forEach((t) => {
(groups[t.group] ??= []).push(t);
});
const order = ["Type", "Priority", "Status", "Area"];
return order.map((value) => ({ value, items: groups[value] ?? [] }));
}
const groupedLabels: LabelGroup[] = groupLabels(labelGroups);
export function ComboboxGrouped() {
return (
<Combobox items={groupedLabels} defaultValue={labelGroups[0]}>
<ComboboxTrigger
render={(props) => (
<Button
variant="outline"
className="w-[200px] justify-between"
{...props}
>
<ComboboxValue />
<ComboboxIcon render={<ChevronsUpDown />} />
</Button>
)}
/>
<ComboboxContent>
<ComboboxInput placeholder="Change label..." />
<ComboboxEmpty>No label found.</ComboboxEmpty>
<ComboboxList className="max-h-[min(calc(23rem-var(--input-container-height)),calc(var(--available-height)-var(--input-container-height)))]">
{(group: LabelGroup) => (
<ComboboxGroup key={group.value} items={group.items}>
<ComboboxGroupLabel>{group.value}</ComboboxGroupLabel>
<ComboboxCollection>
{(label: Label) => (
<ComboboxItem key={label.value} value={label}>
{label.label}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
Creatable
import * as React from "react";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxValue,
} from "@/components/ui/combobox";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface TagItem {
creatable?: string;
value: string;
label: string;
}
const initialTags: TagItem[] = [
{ value: "work", label: "work" },
{ value: "personal", label: "personal" },
{ value: "ideas", label: "ideas" },
{ value: "important", label: "important" },
{ value: "later", label: "read later" },
];
export function ComboboxMultiSelect() {
const containerRef = React.useRef<HTMLDivElement | null>(null);
const createInputRef = React.useRef<HTMLInputElement | null>(null);
const comboboxInputRef = React.useRef<HTMLInputElement | null>(null);
const pendingQueryRef = React.useRef("");
const [tags, setTags] = React.useState<TagItem[]>(initialTags);
const [selected, setSelected] = React.useState<TagItem[]>([]);
const [query, setQuery] = React.useState("");
const [openDialog, setOpenDialog] = React.useState(false);
function handleCreate() {
const input = createInputRef.current || comboboxInputRef.current;
const value = input ? input.value.trim() : "";
if (!value) {
return;
}
const normalized = value.toLocaleLowerCase();
const baseValue = normalized.replace(/\s+/g, "-");
const existing = tags.find(
(l) => l.value.trim().toLocaleLowerCase() === normalized
);
if (existing) {
setSelected((prev) =>
prev.some((i) => i.value === existing.value)
? prev
: [...prev, existing]
);
setOpenDialog(false);
setQuery("");
return;
}
// Ensure we don't collide with an existing value (e.g., value "docs" vs. existing value "docs")
const existingIds = new Set(tags.map((l) => l.value));
let uniqueValue = baseValue;
if (existingIds.has(uniqueValue)) {
let i = 2;
while (existingIds.has(`${baseValue}-${i}`)) {
i += 1;
}
uniqueValue = `${baseValue}-${i}`;
}
const newItem: TagItem = { value: uniqueValue, label: value };
if (!selected.find((item) => item.value === newItem.value)) {
setTags((prev) => [...prev, newItem]);
setSelected((prev) => [...prev, newItem]);
}
setOpenDialog(false);
setQuery("");
}
function handleCreateSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
handleCreate();
}
const trimmed = query.trim();
const lowered = trimmed.toLocaleLowerCase();
const exactExists = tags.some(
(l) => l.value.trim().toLocaleLowerCase() === lowered
);
// Show the creatable item alongside matches if there's no exact match
const itemsForView: Array<TagItem> =
trimmed !== "" && !exactExists
? [
...tags,
{
creatable: trimmed,
value: `create:${lowered}`,
label: `Create "${trimmed}"`,
},
]
: tags;
return (
<React.Fragment>
<Combobox
items={itemsForView}
multiple
onValueChange={(next) => {
const last = next[next.length - 1];
if (last && last.creatable) {
pendingQueryRef.current = last.creatable;
setOpenDialog(true);
return;
}
const clean = next.filter((i) => !i.creatable);
setSelected(clean);
setQuery("");
}}
value={selected}
inputValue={query}
onInputValueChange={setQuery}
onOpenChange={(open, details) => {
if ("key" in details.event && details.event.key === "Enter") {
// When pressing Enter:
// - If the typed value exactly matches an existing item, add that item to the selected chips
// - Otherwise, create a new item
if (trimmed === "") {
return;
}
const existing = tags.find(
(l) => l.value.trim().toLocaleLowerCase() === lowered
);
if (existing) {
setSelected((prev) =>
prev.some((i) => i.value === existing.value)
? prev
: [...prev, existing]
);
setQuery("");
return;
}
pendingQueryRef.current = trimmed;
setOpenDialog(true);
}
}}
>
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="tags">Tags</Label>
<ComboboxChips ref={containerRef}>
<ComboboxValue>
{(value: TagItem[]) => (
<React.Fragment>
{value.map((item) => (
<ComboboxChip key={item.value} aria-label={item.label}>
{item.label}
</ComboboxChip>
))}
<ComboboxInput
ref={comboboxInputRef}
id="tags"
placeholder={value.length > 0 ? "" : "e.g. work"}
/>
</React.Fragment>
)}
</ComboboxValue>
</ComboboxChips>
</div>
<ComboboxContent anchor={containerRef} sideOffset={6}>
<ComboboxEmpty>No language found.</ComboboxEmpty>
<ComboboxList>
{(item: TagItem) =>
item.creatable ? (
<ComboboxItem key={item.value} value={item}>
{item.label}
<Plus className="absolute right-2" />
</ComboboxItem>
) : (
<ComboboxItem key={item.value} value={item}>
{item.label}
</ComboboxItem>
)
}
</ComboboxList>
</ComboboxContent>
</Combobox>
<Dialog open={openDialog} onOpenChange={setOpenDialog}>
<DialogContent
initialFocus={createInputRef}
className="sm:max-w-[425px]"
>
<DialogHeader>
<DialogTitle>Create new tag</DialogTitle>
<DialogDescription>Add a new tag to select.</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreateSubmit} className="flex flex-col gap-4">
<Input
ref={createInputRef}
placeholder="Tag name"
defaultValue={pendingQueryRef.current}
/>
<DialogFooter>
<DialogClose render={<Button variant="outline">Cancel</Button>} />
<Button type="submit">Create</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</React.Fragment>
);
}