eo-n/ui

Combobox

An input field combined with a list of predefined items for easy selection.

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>
  );
}