Combobox
PortedPick from a long list with search. Covers single and multiple choice, remote search and creating new items. Native <button> attributes and ref pass through to the trigger (with inline, there is no trigger).
import { Combobox, type ComboboxItemData } from "@etched/monet/combobox";
const owners: ComboboxItemData[] = [
{ value: "", 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, ComboboxChip, ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxGroupLabel, ComboboxInput, ComboboxItem, ComboboxList, ComboboxRoot, ComboboxTriggerInput, ComboboxTriggerMultipleWithInput, ComboboxTriggerValue } from "@etched/monet/combobox";Examples
Single choice
import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/monet/combobox";
const owners: ComboboxItemData[] = [
{ value: "", 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 />
<Combobox aria-label="Owner, read-only" items={owners} defaultValue="sam" readOnly />
</div>
);
}Long lists
virtualized renders only the rows in view, so 5,000 candidates stay fast. Every row has a description, so all rows are one height. Values can be any type; these are number ids.
Candidate id 4321.
import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/monet/combobox";
const firstNames = "Ana Ben Chen Dana Eli Fatima Gus Hana Ivan Jia Kai Lena Mateo Nina Omar Priya Quinn Rosa Sam Tara Uma Vikram Wen Xin Yara Zoe Aiko Bruno Carmen Dev Emma Felix Grace Hugo Isla Jon Kira Leo Maya Noah Olga Pablo Rhea Sofia Theo Una Victor Will Yusuf Zara".split(" ");
const lastNames = (
"Abe Adler Baker Banda Costa Cruz Dahl Diaz Eze Evans Fox Funk Garcia Gill Hale Han Ito Iyer Jain Jung Kato Khan Lam Lopez Mehta Moss Nagy Ng Ochoa Ono Park Patel Qureshi Quon Reyes Roy " +
"Sato Shah Tan Tran Ueda Ulloa Vance Vega Wang Watts Xu Xiong Yang Yoon Zhou Zubair Arora Bell Choi Doyle Endo Ford Gupta Hart Ibarra Jensen Kaur Lin Mora Nair Ortiz Pham Rao Silva " +
"Tanaka Usman Volkov Wu Young Zhang Bose Cho Das Frey Goh Hsu Iqbal Joshi Kim Liu Meyer Nolan Obi Pena Rossi Singh Teo Ueno Vu Weber Yu Ziegler Ahmed Moreno"
).split(" ");
// 5,000 candidates with number ids: every first and last name pair.
const candidates: ComboboxItemData<number>[] = lastNames.flatMap((last, l) =>
firstNames.map((first, f) => ({ value: l * firstNames.length + f + 1, label: `${first} ${last}`, description: `${first}.${last}@example.com`.toLowerCase() })),
);
export function LongLists() {
const [candidate, setCandidate] = useState<number | null>(4321);
return (
<div className="grid">
<Combobox
label="Candidate"
virtualized
items={candidates}
value={candidate}
onValueChange={setCandidate}
placeholder="Choose a candidate"
description={candidate == null ? "No one chosen." : `Candidate id ${candidate}.`}
/>
</div>
);
}Multiple, create and remote search
import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/monet/combobox";
const owners: ComboboxItemData[] = [
{ value: "", 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
Search by name or email. Accents are optional.
Approvers see every note.
import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/monet/combobox";
const owners: ComboboxItemData[] = [
{ value: "", 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)
import { ComboboxChip, ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxGroupLabel, ComboboxInput, ComboboxItem, ComboboxList, ComboboxRoot, ComboboxTriggerInput, ComboboxTriggerMultipleWithInput, ComboboxTriggerValue } from "@etched/monet/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
import { Combobox, type ComboboxItemData } from "@etched/monet/combobox";
const owners: ComboboxItemData[] = [
{ value: "", 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/monet/combobox";
const owners: ComboboxItemData[] = [
{ value: "", 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/monet/combobox";
const owners: ComboboxItemData[] = [
{ value: "", 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/monet/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>
);
}Accessibility
- On the trigger,
EnterorArrowDownopens the list with focus in its search, and a typed letter starts the search.ArrowUp/ArrowDownmove the highlight;Enterpicks. Escapecloses only the list, also in a dialog.Tab/Shift+Tabclose it and move on. After a pick, focus goes back to the trigger.- The trigger is a
comboboxthat opens adialog, named bylabeloraria-label, as is the list. The search field is acomboboxnamed bysearchLabel. - The trigger has
aria-expanded; options havearia-selected.statusand the empty text are politestatusregions. Afiltertrigger is described by its chosen values. - Without
label, give it anaria-label; thefilterappearance always needs one. SetsearchLabelwhen the search placeholder is not a good name. - With the input parts,
Escapeon a closed list clears the value. Chips are atoolbar;Backspacein an empty field removes the last chip.
API reference
Combobox
| Prop | Type | Default | Description |
|---|---|---|---|
| label | ReactNode | – | Visible label. Without one, give the control an aria-label. |
| labelTooltip | ReactNode | – | More about the field, behind an info button after the label. |
| labelTooltipLabel | string | "More information" | Names the info button. |
| description | ReactNode | – | Help text below the control. Hidden while an error shows. |
| error | FieldError | – | |
| items * | ComboboxItemData<T>[] | – | |
| multiple | Multiple | – | |
| value | Value<T, Multiple> | – | |
| defaultValue | Value<T, Multiple> | – | |
| onValueChange | (value: Value<T, Multiple>) => void | – | |
| itemToStringValue | (value: T) => string | String | A value as a string: its form value, its data-value test hook and its key. Give it when values are objects. |
| required | boolean | – | |
| disabled | boolean | – | |
| readOnly | boolean | – | Keeps the value. The list still opens to show the options. |
| 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<T>) => ReactNode | – | Custom option content, for example a status pill. |
| renderValue | (item: ComboboxItemData<T>) => 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). |
| createLabel | (text: string) => string | `Create “${text}”` | The row that offers the typed text. |
| createHintLabel | string | "Press Enter or Tab to create" | The hint under that row. |
| existsLabel | string | "Already exists" | The hint under an item whose label is the typed text. |
| footer | ReactNode | false | – | Bottom of the list. Multiple selection shows "N selected" and Done unless this is false or set. |
| selectedLabel | (count: number) => string | `${count} selected` | The count of chosen items, on the trigger and in the footer of a multiple selection. |
| doneLabel | string | "Done" | The footer's button that closes the list. |
| inline | boolean | – | Search field and list in place, with no trigger or popup. |
| virtualized | boolean | – | Renders only the rows in view, so a list of thousands stays fast. Every row must have the same height: give every item a description, or none. |
| open | boolean | – | Whether the list is open, when the app controls it. |
| defaultOpen | boolean | false | Whether the list is open at first. |
| 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). |
| closeLabel | string | "Close" | Names the esc key that onClose adds. |
| 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" | |
| container | BaseCombobox.Portal.Props["container"] | document.body | The element the list's portal goes into, for example a shadow root. |
ComboboxChip
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | – | |
| removeLabel | string | "Remove" | Names the remove button. |
ComboboxContent
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | – | |
| children * | ReactNode | – | |
| container | BaseCombobox.Portal.Props["container"] | document.body | The element the portal goes into, for example a shadow root. |
ComboboxEmpty
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | – |
ComboboxGroupLabel
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | – |
ComboboxGroup
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | – |
ComboboxInput
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | – | |
| end | ReactNode | – | After the field in the same row, such as a close key. |
ComboboxItem
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | – | |
| level | 1 | 2 | 3 | 4 | – | A step on the green ramp, for an ordinal score such as conviction 1–4. Wins over hue and tone. A Pill with a level is a small score box, and a list option with one takes the fill across its row. |
ComboboxList
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | – |
ComboboxRoot
| Prop | Type | Default | Description |
|---|---|---|---|
| label | ReactNode | – | Visible label. Without one, give the control an aria-label. |
| labelTooltip | ReactNode | – | More about the field, behind an info button after the label. |
| labelTooltipLabel | string | "More information" | Names the info button. |
| description | ReactNode | – | Help text below the control. Hidden while an error shows. |
| error | FieldError | – | |
| size | "sm" | "base" | "base" | Trigger height, as Input. |
ComboboxTriggerInput
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | – | |
| clearLabel | string | "Clear selection" | Names the clear button. |
| showOptionsLabel | string | "Show options" | Names the button that opens the list. |
ComboboxTriggerMultipleWithInput
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | – | |
| renderItem * | (value: Value) => ReactNode | – | Renders one selected value, usually as a ComboboxChip. |
| inputSide | "right" | "top" | "right" |
ComboboxTriggerValue
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | – |