Combobox
Searchable select for one or many values, fully keyboard driven.
$
pnpm dlx shadcn@latest add https://lodeui.com/r/combobox.jsonCountry
Assignees
components/examples/combobox-demo.tsx
"use client";
import * as React from "react";
import { Combobox } from "@/components/ui/combobox";
const countries = ["India", "United States", "United Kingdom", "Germany", "France", "Japan", "Brazil", "Canada", "Australia", "Singapore", "Netherlands", "Spain"].map((c) => ({ value: c.toLowerCase().replace(/\s/g, "-"), label: c }));
const people = [
{ value: "ada", label: "Ada Lovelace", description: "Engineering" },
{ value: "grace", label: "Grace Hopper", description: "Platform" },
{ value: "alan", label: "Alan Turing", description: "Research" },
{ value: "katherine", label: "Katherine Johnson", description: "Data" },
];
export default function ComboboxDemo() {
const [country, setCountry] = React.useState<string | null>("india");
const [assignees, setAssignees] = React.useState<string[]>(["ada"]);
return (
<div className="grid w-full max-w-xl gap-4 sm:grid-cols-2">
<div><p className="mb-1.5 text-sm font-medium">Country</p><Combobox aria-label="Country" options={countries} value={country} onValueChange={setCountry} /></div>
<div><p className="mb-1.5 text-sm font-medium">Assignees</p><Combobox aria-label="Assignees" multiple options={people} value={assignees} onValueChange={setAssignees} placeholder="Assign people" /></div>
</div>
);
}When to use
- Searchable choice from many options: country, assignee, repository.
- Choosing several values (multiple).
When not to use
- Under ~8 options: use a select.
Installation
$
pnpm dlx shadcn@latest add https://lodeui.com/r/combobox.json1Install the dependencies
npm install motion lucide-react2Copy the source into your project
components/ui/combobox.tsx"use client"; import * as React from "react"; import { AnimatePresence, motion } from "motion/react"; import { Check, ChevronsUpDown, X } from "lucide-react"; import { cn } from "@/lib/utils"; export interface ComboboxOption { value: string; label: string; description?: string; icon?: React.ReactNode; } interface Base { options: ComboboxOption[]; placeholder?: string; searchPlaceholder?: string; emptyMessage?: string; className?: string; "aria-label"?: string; } type Single = Base & { multiple?: false; value: string | null; onValueChange: (v: string | null) => void }; type Multi = Base & { multiple: true; value: string[]; onValueChange: (v: string[]) => void }; export type ComboboxProps = Single | Multi; /** Searchable select, single or multiple. Fuzzy-ish matching on label and description. */ export function Combobox(props: ComboboxProps) { const { options, placeholder = "Select…", searchPlaceholder = "Search…", emptyMessage = "No matches", className } = props; const id = React.useId(); const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(""); const [active, setActive] = React.useState(0); const rootRef = React.useRef<HTMLDivElement>(null); const selected = props.multiple ? props.value : props.value ? [props.value] : []; const q = query.trim().toLowerCase(); const filtered = q ? options.filter((o) => `${o.label} ${o.description ?? ""}`.toLowerCase().includes(q)) : options; React.useEffect(() => { if (!open) return; const onDown = (e: PointerEvent) => !rootRef.current?.contains(e.target as Node) && setOpen(false); document.addEventListener("pointerdown", onDown); return () => document.removeEventListener("pointerdown", onDown); }, [open]); const toggle = (v: string) => { if (props.multiple) props.onValueChange(selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v]); else { props.onValueChange(v === props.value ? null : v); setOpen(false); } }; const label = selected.length === 0 ? null : props.multiple ? null : options.find((o) => o.value === selected[0])?.label; return ( <div ref={rootRef} className={cn("relative w-full", className)}> <button type="button" aria-haspopup="listbox" aria-expanded={open} aria-label={props["aria-label"]} onClick={() => { setOpen((o) => !o); setQuery(""); setActive(0); }} className="flex min-h-10 w-full items-center gap-2 rounded-md border border-input px-3 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent/40 focus-visible:ring-2 focus-visible:ring-ring" > <span className="flex min-w-0 flex-1 flex-wrap gap-1"> {props.multiple && selected.length > 0 ? selected.map((v) => ( <span key={v} className="flex h-6 items-center gap-1 rounded bg-secondary pl-2 pr-1 text-xs font-medium"> {options.find((o) => o.value === v)?.label} <span role="button" tabIndex={-1} aria-label="Remove" onClick={(e) => { e.stopPropagation(); toggle(v); }} className="grid size-4 place-items-center rounded hover:bg-accent"><X className="size-3" /></span> </span> )) : <span className={cn("truncate", !label && "text-muted-foreground")}>{label ?? placeholder}</span>} </span> <ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" /> </button> <AnimatePresence> {open && ( <motion.div initial={{ opacity: 0, y: -4, scale: 0.98 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, scale: 0.98, transition: { duration: 0.1 } }} transition={{ type: "spring", bounce: 0.1, duration: 0.25 }} className="absolute inset-x-0 top-full z-50 mt-1.5 overflow-hidden rounded-lg border border-border bg-popover shadow-elev-3" > <input autoFocus value={query} onChange={(e) => { setQuery(e.target.value); setActive(0); }} onKeyDown={(e) => { if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(filtered.length - 1, a + 1)); } if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(0, a - 1)); } if (e.key === "Enter" && filtered[active]) { e.preventDefault(); toggle(filtered[active].value); } if (e.key === "Escape") setOpen(false); }} role="combobox" aria-expanded aria-controls={`${id}-list`} aria-activedescendant={filtered[active] ? `${id}-${filtered[active].value}` : undefined} placeholder={searchPlaceholder} className="h-10 w-full border-b border-border bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground" /> <ul id={`${id}-list`} role="listbox" aria-multiselectable={props.multiple || undefined} className="max-h-60 overflow-y-auto p-1"> {filtered.length === 0 && <li className="px-3 py-6 text-center text-sm text-muted-foreground">{emptyMessage}</li>} {filtered.map((o, i) => { const isSel = selected.includes(o.value); return ( <li key={o.value} id={`${id}-${o.value}`} role="option" aria-selected={isSel} onPointerMove={() => setActive(i)} onClick={() => toggle(o.value)} className={cn("flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-sm [&_svg]:size-4", i === active && "bg-accent")} > {o.icon && <span className="text-muted-foreground">{o.icon}</span>} <span className="min-w-0 flex-1"> <span className="block truncate">{o.label}</span> {o.description && <span className="block truncate text-xs text-muted-foreground">{o.description}</span>} </span> {isSel && <Check className="text-foreground" />} </li> ); })} </ul> </motion.div> )} </AnimatePresence> </div> ); }
Usage
Import it from @/components/ui/combobox after installing.
example.tsx
import * as React from "react";
import { Combobox } from "@/components/ui/combobox";
const people = [
{ value: "ada", label: "Ada Lovelace", description: "Engineering" },
{ value: "grace", label: "Grace Hopper", description: "Platform" },
];
export function Assignees() {
const [value, setValue] = React.useState<string[]>([]);
return <Combobox multiple aria-label="Assignees" options={people} value={value} onValueChange={setValue} />;
}API reference
Combobox
A trigger with a searchable listbox. Single mode returns string | null; multiple mode returns string[].
| Prop | Type | Default | Description |
|---|---|---|---|
| options | { value; label; description?; icon? }[] | – | Choices; search matches label and description. |
| multiple | boolean | false | Switches value to string[]. |
| value / onValueChange | string | null / string[] | – | Controlled value. Choosing the selected option again clears it in single mode. |
| placeholder / searchPlaceholder / emptyMessage | string | "Select…" / "Search…" / "No matches" | Text. |
Keyboard interactions
| Keys | Action |
|---|---|
| ↑or↓ | Move the highlight |
| Enter | Choose |
| Esc | Close |
Accessibility
- The search field is a combobox controlling a listbox with aria-activedescendant.
- Multiple mode sets aria-multiselectable.
Motion
- The menu scales in; chosen chips animate in the trigger.
Responsive behavior
- Fills its container width.
Performance
- Filtering is a substring match; fine for a few thousand options.
Notes for AI
- Use the multiple prop and string[] state together, or TypeScript will complain.
This page as Markdown, for assistants and coding agents:
/components/combobox/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt