Combobox
Pick from a long list with search. Covers single and multiple choice, remote search and creating new items.
import { Combobox, type ComboboxItemData } from "@etched/reticle/combobox";
const owners: ComboboxItemData[] = [
{ value: "none", label: "Unassigned", pinned: true },
{ value: "ana", label: "Ana Ruiz", description: "ana@etched.com" },
{ value: "jose", label: "José Núñez", description: "jose@etched.com" },
{ value: "sam", label: "Sam Lee", description: "sam@etched.com", searchText: "samuel" },
{ value: "kai", label: "Kai Ito", description: "kai@etched.com" },
{ value: "lee", label: "Lee Park (left)", hidden: true },
];
export function Preview() {
return <Combobox aria-label="Owner" items={owners} placeholder="Choose an owner" />;
}Installation
import { Combobox } from "@etched/reticle/combobox";Examples
Single choice
import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/reticle/combobox";
const owners: ComboboxItemData[] = [
{ value: "none", label: "Unassigned", pinned: true },
{ value: "ana", label: "Ana Ruiz", description: "ana@etched.com" },
{ value: "jose", label: "José Núñez", description: "jose@etched.com" },
{ value: "sam", label: "Sam Lee", description: "sam@etched.com", searchText: "samuel" },
{ value: "kai", label: "Kai Ito", description: "kai@etched.com" },
{ value: "lee", label: "Lee Park (left)", hidden: true },
];
const statuses: ComboboxItemData[] = [
{ value: "new", label: "New", tone: "neutral" },
{ value: "screen", label: "Phone screen", tone: "blue" },
{ value: "onsite", label: "Onsite", tone: "purple" },
{ value: "offer", label: "Offer", tone: "green" },
{ value: "rejected", label: "Rejected", tone: "red" },
{ value: "trial", label: "Work trial (custom)", hue: 236 },
];
const tone = new Map([
["offer", "var(--et-success)"],
["rejected", "var(--et-danger)"],
]);
const pill = (item: ComboboxItemData) => <span style={{ color: tone.get(item.value) ?? "var(--et-text)" }}>● {item.label}</span>;
// Saves on choice and is disabled while it saves, as Orbit's table cells are; focus comes back after.
function SavesOnChoice() {
const [value, setValue] = useState<string | null>("ana");
const [saving, setSaving] = useState(false);
return (
<Combobox
aria-label="Owner, saves on choice"
items={owners}
value={value}
disabled={saving}
onValueChange={(next) => {
setValue(next);
setSaving(true);
setTimeout(() => setSaving(false), 300);
}}
/>
);
}
export function SingleChoice() {
return (
<div className="row">
<Combobox aria-label="Owner" items={owners} placeholder="Choose an owner" searchLabel="Search owners" />
<Combobox aria-label="Former owner" items={owners} defaultValue="lee" />
<Combobox aria-label="Status" items={statuses} defaultValue="offer" renderItem={pill} renderValue={pill} size="sm" />
<Combobox aria-label="Stage" appearance="pill" items={statuses} defaultValue="screen" />
<Combobox aria-label="Owner, disabled" items={owners} defaultValue="ana" disabled />
<SavesOnChoice />
</div>
);
}Multiple, create and remote search
import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/reticle/combobox";
const owners: ComboboxItemData[] = [
{ value: "none", label: "Unassigned", pinned: true },
{ value: "ana", label: "Ana Ruiz", description: "ana@etched.com" },
{ value: "jose", label: "José Núñez", description: "jose@etched.com" },
{ value: "sam", label: "Sam Lee", description: "sam@etched.com", searchText: "samuel" },
{ value: "kai", label: "Kai Ito", description: "kai@etched.com" },
{ value: "lee", label: "Lee Park (left)", hidden: true },
];
const roles: ComboboxItemData[] = ["Design engineer", "Silicon engineer", "Recruiter", "Product manager", "Data scientist"].map((label) => ({
value: label.toLowerCase().replace(/ /g, "-"),
label,
}));
function RemoteSearch() {
const [found, setFound] = useState<ComboboxItemData[]>([]);
const [status, setStatus] = useState("Type to search people");
return (
<Combobox
aria-label="Person"
items={found}
placeholder="Find a person"
status={status}
onSearch={(query) => {
setStatus(query ? "Searching…" : "Type to search people");
setTimeout(() => {
const hits = owners.filter((o) => !o.pinned && !o.hidden && o.label.toLowerCase().includes(query.toLowerCase()));
setFound(query ? hits : []);
setStatus(query && !hits.length ? "No people match." : "");
}, 300);
}}
/>
);
}
export function MultipleCreateAndRemoteSearch() {
return (
<div className="row">
<Combobox aria-label="Roles" multiple items={roles} defaultValue={["recruiter"]} placeholder="Any role" />
<RemoteSearch />
</div>
);
}In a form
Owner
Search by name or email. Accents are optional.
Label
Reviewer
Choose a reviewer before you submit.
Approvers see every note.
import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/reticle/combobox";
const owners: ComboboxItemData[] = [
{ value: "none", label: "Unassigned", pinned: true },
{ value: "ana", label: "Ana Ruiz", description: "ana@etched.com" },
{ value: "jose", label: "José Núñez", description: "jose@etched.com" },
{ value: "sam", label: "Sam Lee", description: "sam@etched.com", searchText: "samuel" },
{ value: "kai", label: "Kai Ito", description: "kai@etched.com" },
{ value: "lee", label: "Lee Park (left)", hidden: true },
];
function Labels() {
const [items, setItems] = useState<ComboboxItemData[]>([{ value: "urgent", label: "Urgent" }, { value: "referral", label: "Referral" }]);
const [value, setValue] = useState<string | null>(null);
return (
<Combobox
label="Label"
items={items}
value={value}
onValueChange={setValue}
placeholder="Choose or create a label"
onCreate={(label) => {
const item = { value: label.toLowerCase(), label };
setItems((all) => [...all, item]);
setValue(item.value);
}}
/>
);
}
/** Labelled and described by elements around it, and told when it opens or closes. */
function LabelledElsewhere() {
const [open, setOpen] = useState(false);
return (
<div className="grid">
<label htmlFor="combobox-elsewhere">Approver</label>
<Combobox id="combobox-elsewhere" aria-describedby="combobox-elsewhere-hint" items={owners} placeholder="Choose an approver" onOpenChange={setOpen} />
<p id="combobox-elsewhere-hint">{open ? "The list is open." : "Approvers see every note."}</p>
</div>
);
}
export function InAForm() {
return (
<div className="grid">
<Combobox label="Owner" items={owners} placeholder="Choose an owner" description="Search by name or email. Accents are optional." />
<Labels />
<Combobox label="Reviewer" items={owners} placeholder="Choose a reviewer" error="Choose a reviewer before you submit." />
<LabelledElsewhere />
</div>
);
}Composable parts (Kumo)
B-14
import { ComboboxChip, ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxGroupLabel, ComboboxInput, ComboboxItem, ComboboxList, ComboboxRoot, ComboboxTriggerInput, ComboboxTriggerMultipleWithInput, ComboboxTriggerValue } from "@etched/reticle/combobox";
const benches = ["B-12", "B-14", "B-15", "P-02", "P-07", "T-01"];
const sites = [
{ value: "Taiwan", items: ["TPE-1", "TPE-2"] },
{ value: "United States", items: ["SJC-1", "AUS-3"] },
];
export function ComposablePartsKumo() {
return (
<div className="grid">
<ComboboxRoot label="Bench" items={benches}>
<ComboboxTriggerInput placeholder="Type a bench" />
<ComboboxContent>
<ComboboxEmpty>No benches match.</ComboboxEmpty>
<ComboboxList>
{(bench: string) => (
<ComboboxItem key={bench} value={bench}>
{bench}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</ComboboxRoot>
<ComboboxRoot label="Benches" items={benches} multiple defaultValue={["B-14"]}>
<ComboboxTriggerMultipleWithInput<string>
placeholder="Add a bench"
renderItem={(bench) => <ComboboxChip key={bench}>{bench}</ComboboxChip>}
/>
<ComboboxContent>
<ComboboxEmpty />
<ComboboxList>
{(bench: string) => (
<ComboboxItem key={bench} value={bench}>
{bench}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</ComboboxRoot>
<ComboboxRoot label="Site" items={sites}>
<ComboboxTriggerValue placeholder="Choose a site" />
<ComboboxContent>
<ComboboxInput placeholder="Search sites" />
<ComboboxEmpty />
<ComboboxList>
{(group: (typeof sites)[number]) => (
<ComboboxGroup key={group.value} items={group.items}>
<ComboboxGroupLabel>{group.value}</ComboboxGroupLabel>
<ComboboxCollection>
{(site: string) => (
<ComboboxItem key={site} value={site}>
{site}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</ComboboxRoot>
</div>
);
}Inline
Unassigned
Ana Ruizana@etched.com
José Núñezjose@etched.com
Sam Leesam@etched.com
Kai Itokai@etched.com
import { Combobox, type ComboboxItemData } from "@etched/reticle/combobox";
const owners: ComboboxItemData[] = [
{ value: "none", label: "Unassigned", pinned: true },
{ value: "ana", label: "Ana Ruiz", description: "ana@etched.com" },
{ value: "jose", label: "José Núñez", description: "jose@etched.com" },
{ value: "sam", label: "Sam Lee", description: "sam@etched.com", searchText: "samuel" },
{ value: "kai", label: "Kai Ito", description: "kai@etched.com" },
{ value: "lee", label: "Lee Park (left)", hidden: true },
];
export function Inline() {
return (
<div className="grid">
<Combobox aria-label="Add a person" inline items={owners} searchPlaceholder="Search people" />
</div>
);
}Palette
inline with appearance="palette": a command palette for a dialog, as in Orbit's candidate search. Nothing shows under the search until you type; onClose adds the esc key, and Escape in the search calls it too.
Closed 0 times
import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/reticle/combobox";
const owners: ComboboxItemData[] = [
{ value: "none", label: "Unassigned", pinned: true },
{ value: "ana", label: "Ana Ruiz", description: "ana@etched.com" },
{ value: "jose", label: "José Núñez", description: "jose@etched.com" },
{ value: "sam", label: "Sam Lee", description: "sam@etched.com", searchText: "samuel" },
{ value: "kai", label: "Kai Ito", description: "kai@etched.com" },
{ value: "lee", label: "Lee Park (left)", hidden: true },
];
export function Palette() {
const [found, setFound] = useState<ComboboxItemData[]>([]);
const [closed, setClosed] = useState(0);
return (
<div className="card">
<Combobox
aria-label="Find a person"
inline
appearance="palette"
items={found}
searchPlaceholder="Search people or teams"
onSearch={(query) => setFound(query ? owners.filter((o) => !o.pinned && !o.hidden && o.label.toLowerCase().includes(query.toLowerCase())) : [])}
onClose={() => setClosed((count) => count + 1)}
/>
<p className="caption">Closed {closed} {closed === 1 ? "time" : "times"}</p>
</div>
);
}Filter
appearance="filter" is an icon-only funnel for a column heading. It fills while a filter is on; screen readers hear the choice as its description.
import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/reticle/combobox";
const owners: ComboboxItemData[] = [
{ value: "none", label: "Unassigned", pinned: true },
{ value: "ana", label: "Ana Ruiz", description: "ana@etched.com" },
{ value: "jose", label: "José Núñez", description: "jose@etched.com" },
{ value: "sam", label: "Sam Lee", description: "sam@etched.com", searchText: "samuel" },
{ value: "kai", label: "Kai Ito", description: "kai@etched.com" },
{ value: "lee", label: "Lee Park (left)", hidden: true },
];
const statuses: ComboboxItemData[] = [
{ value: "new", label: "New", tone: "neutral" },
{ value: "screen", label: "Phone screen", tone: "blue" },
{ value: "onsite", label: "Onsite", tone: "purple" },
{ value: "offer", label: "Offer", tone: "green" },
{ value: "rejected", label: "Rejected", tone: "red" },
{ value: "trial", label: "Work trial (custom)", hue: 236 },
];
const tone = new Map([
["offer", "var(--et-success)"],
["rejected", "var(--et-danger)"],
]);
export function Filter() {
const [shown, setShown] = useState<string[]>(["screen", "offer"]);
return (
<div className="row">
<Combobox multiple appearance="filter" aria-label="Filter by status" items={statuses} value={shown} onValueChange={setShown} />
<Combobox multiple appearance="filter" aria-label="Filter by owner" items={owners} defaultValue={[]} />
</div>
);
}Score box
appearance="box" with items that have a level: the trigger is a small box in the score's fill, and each option fills its row. A legacy score with no level has no fill.
import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/reticle/combobox";
const conviction: ComboboxItemData[] = [
{ value: "1", label: "1", level: 1, description: "Profile looks good, unsure" },
{ value: "2", label: "2", level: 2, description: "Profile looks extra good, unsure" },
{ value: "3", label: "3", level: 3, description: "Additional backchannel or tribal knowledge about this person" },
{ value: "4", label: "4", level: 4, description: "Certified superstar has been deeply referenced" },
{ value: "10", label: "10", hidden: true },
];
export function ScoreBox() {
const [score, setScore] = useState<string | null>("3");
return (
<div className="row">
<Combobox aria-label="Conviction" appearance="box" size="sm" items={conviction} value={score} onValueChange={setScore} />
<Combobox aria-label="Legacy conviction" appearance="box" size="sm" items={conviction} defaultValue="10" />
</div>
);
}API reference
Combobox
| Prop | Type | Default | Description |
|---|---|---|---|
| items * | ComboboxItemData[] | – | |
| multiple | Multiple | – | |
| value | Value<Multiple> | – | |
| defaultValue | Value<Multiple> | – | |
| onValueChange | (value: Value<Multiple>) => void | – | |
| label | ReactNode | – | Visible label. Without one, give the combobox an aria-label. |
| aria-label | string | – | |
| description | ReactNode | – | |
| error | FieldError | – | |
| required | boolean | – | |
| disabled | boolean | – | |
| name | string | – | Form field name. |
| size | "sm" | "base" | "base" | Same heights as Input. |
| appearance | "default" | "pill" | "ghost" | "filter" | "box" | "palette" | "default" | pill is a rounded, borderless trigger for statuses in tables (Orbit\'s status dropdown). ghost has no edge or fill until hover, for values in table cells; it takes the selected option\'s ink. |
| placeholder | ReactNode | – | Trigger text while nothing is selected. |
| searchPlaceholder | string | "Search" | |
| searchLabel | string | `searchPlaceholder` | Names the search field, when its placeholder is not a good name. |
| renderItem | (item: ComboboxItemData) => ReactNode | – | Custom option content, for example a status pill. |
| renderValue | (item: ComboboxItemData) => ReactNode | – | Custom trigger content for the selected item (single selection). |
| onSearch | (query: string) => void | – | Remote search: called on every query. Local filtering turns off. |
| status | ReactNode | – | Live status under the list, for example "Searching…". |
| emptyText | ReactNode | "No matches." | |
| onCreate | (label: string) => void | – | Offers the typed text as a new item when no label matches it (single selection). |
| footer | ReactNode | false | – | Bottom of the list. Multiple selection shows "N selected" and Done unless this is false or set. |
| inline | boolean | – | Search field and list in place, with no trigger or popup. |
| id | string | – | The trigger's id, for a label elsewhere (<label htmlFor>). |
| aria-describedby | string | – | Help text elsewhere that describes the trigger. |
| onOpenChange | (open: boolean) => void | – | Called when the list opens or closes. |
| onClose | () => void | – | With inline: an esc key at the end of the search row, and Escape in the search, call it (a palette in a dialog). |
| className | string | – | Class on the trigger (or the inline panel), for layout such as width. |
| side | BaseCombobox.Positioner.Props["side"] | "bottom" | |
| align | BaseCombobox.Positioner.Props["align"] | "start" |
ComboboxContent
| Prop | Type | Default | Description |
|---|---|---|---|
| children * | ReactNode | – | |
| className | string | – |
ComboboxRoot
| Prop | Type | Default | Description |
|---|---|---|---|
| label | ReactNode | – | Visible label, with help text and error below, as in Field. |
| description | ReactNode | – | |
| error | FieldError | – | |
| size | "sm" | "base" | "base" | Trigger height, as Input. |