Combobox
A select you can type into. The list docks under the field like Select's, the typed text is underlined in each match, and the chosen option keeps its mark.
Loading the live example…
"use client"
import { useState } from "react"
import { Combobox, type ComboboxOption } from "@/components/keyline/ui/combobox"
const CLIENTS: ComboboxOption[] = [
{ value: "blue-cross", label: "Blue Cross of Pennsylvania", code: "BC", group: "Scranton" },
{ value: "business-park", label: "Scranton Business Park", code: "SBP", group: "Scranton" },
{ value: "lackawanna", label: "Lackawanna County", code: "LC", group: "Scranton" },
{ value: "library", label: "Wilkes-Barre Library", code: "WBL", group: "Wilkes-Barre" },
{ value: "harper", label: "Harper Collins, regional office", code: "HC", group: "Wilkes-Barre" },
{ value: "steamtown", label: "Steamtown Mall", code: "STM", group: "Scranton", disabled: true, description: "Account on hold" },
]
export function FindClient() {
const [client, setClient] = useState<ComboboxOption | null>(null)
return (
<div className="grid max-w-[400px] gap-3">
<Combobox label="Client" placeholder="Search clients" noun={["client", "clients"]} options={CLIENTS} onValueChange={(_, o) => setClient(o)} />
<p role="status" className="text-[14px] text-kl-ink-2">
{client ? `New order for ${client.label}.` : "Type part of a name, such as “barre”."}
</p>
</div>
)
}Installation
npx shadcn@latest add @keyline-ui/comboboxRun it where your components.json is. The first time, map the @keyline-ui registry: one command, in Installation.
Usage
import { Combobox } from "@/components/keyline/ui/combobox"<Combobox
label="Client"
noun={["client", "clients"]}
options={[
{ value: "blue-cross", label: "Blue Cross of Pennsylvania", code: "BC" },
{ value: "library", label: "Wilkes-Barre Library", code: "WBL" },
]}
onValueChange={(value, option) => setClient(option)}
/>Examples
Async search
With loadOptions, a status line in the list says what it holds: searching, how many matched, or what went wrong, with Retry. Search for “stamford” to see the error.
Loading the live example…
"use client"
import { useState } from "react"
import { Combobox, type ComboboxOption } from "@/components/keyline/ui/combobox"
const STAFF = [
["Jan Levinson", "VP, Northeast Sales", "New York"],
["David Wallace", "CFO", "New York"],
["Karen Filippelli", "Regional Manager", "Utica"],
["Josh Porter", "Regional Manager", "Stamford"],
["Craig", "Regional Manager", "Albany"],
["Holly Flax", "HR", "Nashua"],
]
// stands in for the corporate directory API: slow, and it times out on "stamford"
function searchDirectory(query: string, signal: AbortSignal): Promise<ComboboxOption[]> {
return new Promise((resolve, reject) => {
const t = setTimeout(() => {
if (query.toLowerCase().includes("stamford")) return reject(new Error("The corporate directory timed out."))
const q = query.toLowerCase()
resolve(
STAFF.filter((s) => s.join(" ").toLowerCase().includes(q)).map(([name, role, branch]) => ({
value: name,
label: name,
description: `${role}, ${branch}`,
})),
)
}, 500)
signal.addEventListener("abort", () => clearTimeout(t))
})
}
export function CorporateDirectory() {
const [who, setWho] = useState<string | null>(null)
return (
<div className="grid max-w-[400px] gap-3">
<Combobox label="Escalate to" placeholder="Search corporate" noun={["person", "people"]} loadOptions={searchDirectory} value={who} onValueChange={setWho} />
<p role="status" className="text-[14px] text-kl-ink-2">
{who ? `${who} gets the complaint. Michael will not be cc'd.` : "Try “utica”, or “stamford” to see a failed search."}
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | Default — | Names the field. Required. |
options | ComboboxOption[] | Default [] | { value, label, description?, code?, group?, disabled? }, filtered as you type. With loadOptions, the ones shown before a search. |
loadOptions | (query, signal) => Promise<ComboboxOption[]> | Default — | An async source, called on open and on each edit. A newer search aborts the older one through signal; a rejection shows its message with Retry. |
value | string | null | Default — | The chosen option's value. onValueChange also gets the option. |
noun | [string, string] | Default ["option", "options"] | Names what is listed, in the status line and the empty state. |
clearable | boolean | Default true | Shows a clear cell while something is chosen. |
name | string | Default — | Submits the chosen value with a form. required and validate work as on any field. |
error | ReactNode | Default — | The message when a check fails. With invalid it always shows. |
description | ReactNode | Default — | A line under the field, read out with it. |
layout | "control" | "cell" | Default "control" | cell drops the frame and hides the label, to sit inside another frame. |
Escape closes the list. For several values, use Multiselect. Built on Base UI Combobox.