Combobox
A searchable select for long lists, with single or multiple values as chips.
INV-20418
Northwind Freight, $18,240.00, due October 12
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList,} from "@oration/canon/components/combobox";import { Label } from "@oration/canon/components/label";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() { type Person = { id: string; name: string; team: string }; const people: Person[] = [ { id: "maya", name: "Maya Okafor", team: "Revenue" }, { id: "priya", name: "Priya Raman", team: "Accounts payable" }, { id: "tomas", name: "Tomás Ferreira", team: "Treasury" }, { id: "jordan", name: "Jordan Lee", team: "Procurement" }, { id: "aisha", name: "Aisha Bello", team: "Accounts payable" }, { id: "wen", name: "Wen Zhou", team: "Controller" }, ]; const id = React.useId(); const [approver, setApprover] = React.useState<Person | null>(null); return ( <div className="flex w-full max-w-sm flex-col gap-4 rounded-xl bg-card p-4 text-left shadow-border"> <div className="flex flex-col gap-0.5"> <p className="text-sm font-medium">INV-20418</p> <p className="text-13 text-muted-foreground"> Northwind Freight,{" "} <span className="tabular-nums">$18,240.00</span>, due October 12 </p> </div> <div className="flex flex-col gap-1.5"> <Label htmlFor={id}>Approver</Label> <Combobox items={people} value={approver} itemToStringLabel={(person: Person) => person.name} onValueChange={(person: Person | null) => { setApprover(person); if (person) { toast.add({ type: "success", title: `Approval requested from ${person.name}`, description: "They get an email and a task in their queue.", }); } }} > <ComboboxInput id={id} placeholder="Search people" className="w-full" /> <ComboboxContent> <ComboboxEmpty> No one on the team by that name. </ComboboxEmpty> <ComboboxList> {(person: Person) => ( <ComboboxItem key={person.id} value={person}> <span className="flex-1 truncate"> {person.name} </span> <span className="text-xs text-muted-foreground"> {person.team} </span> </ComboboxItem> )} </ComboboxList> </ComboboxContent> </Combobox> </div> </div> );}Usage#
Combobox is a text field that filters a list as you type, built on Base UI Combobox. It is the picker for lists that are long, grow with the workspace or are easier to type than to scan: people, suppliers, GL accounts, invoices. With multiple it holds several values as removable chips in the field. The common mistake is reaching for it on a short fixed list, where a search box is only friction; under about 15 options, use a select.
When to use
- To pick one record from a list people search by name: an approver, a supplier, a GL account.
- For several values from a searchable list, shown as chips in the field: watchers, approvers, email domains.
- As an add control above a list, where each pick adds a row and the field clears for the next one.
- When the list is long enough that scanning it is slower than typing three letters, about 15 options or more.
When not to use
- For a short fixed list such as payment terms or remittance format. Use Select field
- For a short list whose rows need icons, tags or groups, without search. Use Select
- For a time zone. It already searches by city, zone and offset. Use Timezone select
- For free-form values people type rather than choose, such as email domains that aren't in any list. Use Tag input
- For jumping to a page or running a command. Use Command menu
Every field has a label
Label to the input's id, or give the input an aria-label when it sits above the list it adds to. The placeholder says what to type, never what the field is.Selects match inputs
Anatomy#
- Input.
ComboboxInput: an Input group with the text input, 32px with 10px corners.classNamesizes the group. - Trigger. A 24px ghost icon button with the chevron that opens the list. With
showClear, a clear button replaces it once there is a value. - Popup. Popover White, 10px corners, the overlay shadow and a 1px ink ring at 10%. At least the field's width plus 28px, 6px below it, scrolling at 18rem.
- Group label.
ComboboxLabel: 12px Slate Meta over a group of rows. - Item.
ComboboxItem: 14px text with 8px corners and room for the check. The highlighted row fills with the accent color. - Indicator. A 16px check, 8px from the right, on the chosen rows.
- Chips. With
multiple:ComboboxChipsreplaces the input group, wraps chips and aComboboxChipsInput, and grows from 32px. - Chip.
ComboboxChip: 21px, 12px medium text on Well Gray with 6px corners and a remove button.
Examples#
Search a long list
Pass items and a function child to ComboboxList; typing filters the rows. ComboboxEmpty says what to do when nothing matches.
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList,} from "@oration/canon/components/combobox";import { Label } from "@oration/canon/components/label";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Basic() { const suppliers = [ "Brightline Freight", "Cobalt Packaging", "Delmar Office Supply", "Evergreen Janitorial", "Fairway Print Co.", "Granite Ridge Staffing", "Halcyon", "Halvorsen Logistics", "Keystone Software", "Lakeside Utilities", "Meridian Legal", "Northwind Freight", "Orchard Street", "Pinecrest Catering", "Quarry Hill Security", "Redwood Insurance", ]; const id = React.useId(); const [supplier, setSupplier] = React.useState<string | null>(null); return ( <div className="flex w-full max-w-xs flex-col gap-1.5 text-left"> <Label htmlFor={id}>Supplier</Label> <Combobox items={suppliers} value={supplier} onValueChange={(next: string | null) => { setSupplier(next); if (next) toast.add({ title: "Supplier set", description: next }); }} > <ComboboxInput id={id} placeholder="Search suppliers" className="w-full" /> <ComboboxContent> <ComboboxEmpty> No suppliers match. Check the spelling or add one. </ComboboxEmpty> <ComboboxList> {(name: string) => ( <ComboboxItem key={name} value={name}> {name} </ComboboxItem> )} </ComboboxList> </ComboboxContent> </Combobox> </div> );}Clearable
showClear swaps the chevron for a clear button once there is a value, and the root reports null.
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList,} from "@oration/canon/components/combobox";import { Label } from "@oration/canon/components/label";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function WithClear() { const owners = [ "Maya Okafor", "Priya Raman", "Tomás Ferreira", "Jordan Lee", "Aisha Bello", "Wen Zhou", ]; const id = React.useId(); const [owner, setOwner] = React.useState<string | null>("Priya Raman"); return ( <div className="flex w-full max-w-xs flex-col gap-1.5 text-left"> <Label htmlFor={id}>Exception owner</Label> <Combobox items={owners} value={owner} onValueChange={(next: string | null) => { setOwner(next); toast.add({ title: next ? `${next} owns this exception` : "Owner removed", description: next ? undefined : "It goes back to the AP exceptions queue.", }); }} > <ComboboxInput id={id} placeholder="Unassigned" showClear className="w-full" /> <ComboboxContent> <ComboboxEmpty>No one by that name.</ComboboxEmpty> <ComboboxList> {(name: string) => ( <ComboboxItem key={name} value={name}> {name} </ComboboxItem> )} </ComboboxList> </ComboboxContent> </Combobox> </div> );}Groups
Pass { value, items } groups to the root, then render each with ComboboxGroup, a ComboboxLabel and a ComboboxCollection. Empty groups drop out as you type.
import { Combobox, ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxLabel, ComboboxList, ComboboxSeparator,} from "@oration/canon/components/combobox";import { Label } from "@oration/canon/components/label";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Groups() { const accounts = [ { value: "Expenses", items: [ "6100 Freight and shipping", "6200 Software subscriptions", "6300 Office supplies", "6400 Professional fees", ], }, { value: "Liabilities", items: [ "2000 Accounts payable", "2100 Accrued expenses", "2200 Sales tax payable", ], }, { value: "Assets", items: ["1400 Prepaid expenses", "1500 Fixed assets"], }, ]; const id = React.useId(); const [account, setAccount] = React.useState<string | null>( "6100 Freight and shipping", ); return ( <div className="flex w-full max-w-xs flex-col gap-1.5 text-left"> <Label htmlFor={id}>GL account</Label> <Combobox items={accounts} value={account} onValueChange={(next: string | null) => { setAccount(next); if (next) toast.add({ title: "Line coded", description: next }); }} > <ComboboxInput id={id} placeholder="Search accounts" className="w-full" /> <ComboboxContent> <ComboboxEmpty>No accounts match.</ComboboxEmpty> <ComboboxList> {( group: { value: string; items: string[] }, index: number, ) => ( <ComboboxGroup key={group.value} items={group.items} > {index > 0 ? <ComboboxSeparator /> : null} <ComboboxLabel>{group.value}</ComboboxLabel> <ComboboxCollection> {(name: string) => ( <ComboboxItem key={name} value={name}> <span className="tabular-nums"> {name} </span> </ComboboxItem> )} </ComboboxCollection> </ComboboxGroup> )} </ComboboxList> </ComboboxContent> </Combobox> </div> );}Several values as chips
With multiple, chosen values become chips in the field and the list stays open. Share useComboboxAnchor() between ComboboxChips and ComboboxContent so the popup lines up with the whole box.
2 people get an email when the payment clears.
import { Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput, ComboboxContent, ComboboxEmpty, ComboboxItem, ComboboxList, ComboboxValue, useComboboxAnchor,} from "@oration/canon/components/combobox";import { Label } from "@oration/canon/components/label";import * as React from "react";export function MultipleWithChips() { const people = [ "Maya Okafor", "Priya Raman", "Tomás Ferreira", "Jordan Lee", "Aisha Bello", "Wen Zhou", ]; const id = React.useId(); const anchor = useComboboxAnchor(); const [watchers, setWatchers] = React.useState<string[]>([ "Priya Raman", "Wen Zhou", ]); return ( <div className="flex w-full max-w-sm flex-col gap-1.5 text-left"> <Label htmlFor={id}>Notify when paid</Label> <Combobox multiple autoHighlight items={people} value={watchers} onValueChange={setWatchers} > <ComboboxChips ref={anchor}> <ComboboxValue> {(values: string[]) => ( <> {values.map((name) => ( <ComboboxChip key={name}> {name} </ComboboxChip> ))} <ComboboxChipsInput id={id} placeholder={ values.length ? "" : "Add people" } /> </> )} </ComboboxValue> </ComboboxChips> <ComboboxContent anchor={anchor}> <ComboboxEmpty>No one by that name.</ComboboxEmpty> <ComboboxList> {(name: string) => ( <ComboboxItem key={name} value={name}> {name} </ComboboxItem> )} </ComboboxList> </ComboboxContent> </Combobox> <p className="text-xs text-muted-foreground"> {watchers.length === 0 ? "No one is notified. The supplier still gets the remittance." : `${watchers.length} ${watchers.length === 1 ? "person gets" : "people get"} an email when the payment clears.`} </p> </div> );}Add to a list
Keep value={null} and add the pick to your own list. The field clears for the next name, and the candidates shrink as people are added.
Payment run approvers
- Priya RamanAccounts payable
- Aisha BelloAccounts payable
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList,} from "@oration/canon/components/combobox";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function AddToList() { type Member = { id: string; name: string; team: string }; const everyone: Member[] = [ { id: "maya", name: "Maya Okafor", team: "Revenue" }, { id: "priya", name: "Priya Raman", team: "Accounts payable" }, { id: "tomas", name: "Tomás Ferreira", team: "Treasury" }, { id: "jordan", name: "Jordan Lee", team: "Procurement" }, { id: "aisha", name: "Aisha Bello", team: "Accounts payable" }, { id: "wen", name: "Wen Zhou", team: "Controller" }, ]; const [memberIds, setMemberIds] = React.useState(["priya", "aisha"]); const members = everyone.filter((m) => memberIds.includes(m.id)); const candidates = everyone.filter((m) => !memberIds.includes(m.id)); return ( <div className="w-full max-w-md rounded-xl bg-card text-left shadow-border"> <div className="flex flex-col gap-3 border-b border-border p-4"> <p className="text-sm font-medium">Payment run approvers</p> <Combobox items={candidates} value={null} itemToStringLabel={(member: Member) => member.name} onValueChange={(member: Member | null) => { if (!member) return; setMemberIds((ids) => [...ids, member.id]); toast.add({ type: "success", title: `Added ${member.name} as an approver`, }); }} > <ComboboxInput placeholder="Add an approver by name" aria-label="Add an approver" className="w-full" /> <ComboboxContent> <ComboboxEmpty> Everyone is already an approver. </ComboboxEmpty> <ComboboxList> {(member: Member) => ( <ComboboxItem key={member.id} value={member}> <span className="flex-1 truncate"> {member.name} </span> <span className="text-xs text-muted-foreground"> {member.team} </span> </ComboboxItem> )} </ComboboxList> </ComboboxContent> </Combobox> </div> <ul className="flex flex-col p-2"> {members.map((member) => ( <li key={member.id} className="flex h-9 items-center justify-between gap-3 rounded-lg px-2 text-13 hover:bg-muted" > <span className="truncate">{member.name}</span> <span className="text-xs text-muted-foreground"> {member.team} </span> </li> ))} </ul> </div> );}States#
import { Combobox, ComboboxInput } from "@oration/canon/components/combobox";import { cn } from "@oration/canon/lib/utils";export function StatesRow() { const states = [ { name: "Rest", className: "", invalid: false, disabled: false }, { name: "Focus", className: "border-ring ring-3 ring-ring/50", invalid: false, disabled: false, }, { name: "Invalid", className: "", invalid: true, disabled: false }, { name: "Disabled", className: "", invalid: false, disabled: true }, ]; return ( <div className="grid w-full grid-cols-2 gap-4 sm:grid-cols-4"> {states.map((state) => ( <div key={state.name} className="flex flex-col gap-1.5"> <span className="text-xs text-muted-foreground"> {state.name} </span> <Combobox items={["Halcyon"]} defaultValue="Halcyon" disabled={state.disabled} > <ComboboxInput aria-label={`Supplier, ${state.name.toLowerCase()}`} aria-invalid={state.invalid || undefined} disabled={state.disabled} tabIndex={-1} className={cn( "pointer-events-none w-full", state.className, )} /> </Combobox> </div> ))} </div> );}| State | Treatment |
|---|---|
| Rest | Transparent field with the Field Stroke and a Slate Meta placeholder. |
| Focus visible | Indigo border and a 3px Focus Indigo ring at 50% on the whole group, drawn when the input has focus. |
| Open | The popup fades and zooms in from 95% over 100ms, sliding 8px from the field. |
| Highlighted | The row under the pointer or arrow keys fills with the accent color. |
| Selected | A check at the right of the row. |
| Empty | ComboboxEmpty shows its message in 14px Slate Meta when nothing matches the text. |
| Invalid | With aria-invalid on the input, a red border and a 3px red ring at 20% on the group or the chips box. |
| Disabled | The group drops to 50% opacity on a faint input fill. A disabled item drops to 50% and can't be highlighted. |
Behavior#
- Pass
itemsto the root and a function as the child ofComboboxList; Base UI filters the items against the text and renders only the matches. For groups, pass{ value, items }[]and render each withComboboxGroup itemsandComboboxCollection. - Objects work as items. Give the root
itemToStringLabelso the input shows a name and the filter matches it; setisItemEqualToValuewhen the value and the items aren't the same objects. - Controlled with
valueandonValueChange, uncontrolled withdefaultValue. Single mode reportsnullwhen cleared. - For an add control, keep
value={null}and push the chosen item into your own list inonValueChange; the field clears for the next pick. - With
multiple, the list stays open after each pick. Anchor the popup to the chips withuseComboboxAnchor(), passing the ref toComboboxChipsandanchortoComboboxContent. - Typing opens the list;
autoHighlighthighlights the first match so Enter picks it. Escape closes the list, and focus stays in the input. - The popup portals to the body and flips to stay in view.
Choosing a select#
Six components pick a value from a list. A time zone is always Timezone select. Past about 15 options, or for several values, use Combobox. Otherwise decide by what a row has to show and where the control sits.
| Component | Reach for it when | List length | Rows show |
|---|---|---|---|
| Select | Rows need icons, tags, groups or separators, the trigger shows a formatted value, or people pick several values from a short list. You compose the parts. | 2 to about 15 | Anything: icons, tags, two-line rows |
| Option select | A flat list of strings or { value, label } pairs in a dense inspector, run bar or toolbar. One line, full width, 13px. | 2 to about 15 | Text |
| Select field | A typed value in a settings row, agent config page, sheet form or filter bar. The preset sets the width and height for that surface. | 2 to about 15 | Text |
| Native select | The platform picker is the better control: phone-first forms, or a plain list that must post with a native form. | Any, plain labels | Text, grouped by optgroup |
| Combobox (this page) | People, suppliers, invoices or any list long enough to search, or several values shown as removable chips. | About 15 or more, or unknown | Anything; filters as you type |
| Timezone select | A time zone. Always, instead of a hand-written list of zones. | Every IANA zone | City, zone, offset and local time |
Do and don't#
ComboboxEmpty out. When nothing matches, the popup collapses to nothing and people think it broke.Content#
- The label names the field: Approver, Supplier, GL account.
- The placeholder says what to type: Search people, Add an approver by name. Not Select….
- The empty message names the miss and a way forward: No suppliers match. Check the spelling or add one.
- A trailing detail in a row, such as the team, is 12px Slate Meta and helps tell two similar names apart.
- In an add control, say when there's no one left: Everyone is already an approver.
Accessibility#
- The input has
role="combobox"witharia-expanded,aria-controlsandaria-activedescendant, so focus stays in the text while arrow keys move through the list. - Name the input with a
Labellinked byid, oraria-labelwhen there's no visible label. - The chevron trigger and the chip remove buttons are out of the tab order (
tabIndex=-1); keyboard users open with the arrow keys and remove chips with Backspace. - Set
aria-invalidon the input for errors and link the message witharia-describedby. - Every item in a list of objects needs a unique
keyand a readable string label for the input.
| Keys | Action |
|---|---|
| ↓ | Opens the list, then moves the highlight down. |
| ↑ | Moves the highlight up. |
| Enter | Chooses the highlighted item. In multiple mode, toggles it. |
| Esc | Closes the list. Focus stays in the input. |
| ← | With chips, from the start of the text, highlights the last chip; then moves between chips. |
| Backspace | With chips, removes the highlighted chip, or the last chip when the text is empty. |
Design tokens#
| Token | Used for |
|---|---|
--input | Field Stroke; 30% fill in dark and disabled |
--ring | Focus border and 3px ring at 50% |
--destructive | Invalid border and ring |
--popover | Popup surface |
shadow-md | Popup lift |
--accent | Highlighted row |
--muted | Chip fill |
--muted-foreground | Placeholder, chevron, group label, empty message |
--radius-lg | 10px field and popup corners |
API reference#
Combobox
The root, Base UI Combobox.Root as is. Holds the value, the text and the open state.
Other props spread onto Base UI Combobox.Root.
| Prop | Type | Default | Description |
|---|---|---|---|
items | readonly Item[] | readonly { value: string; items: Item[] }[] | No default | The list to filter. Groups use { value, items } objects. |
value | Value | Value[] | null | No default | Controlled value. An array with multiple. |
defaultValue | Value | Value[] | null | No default | Initial value when uncontrolled. |
onValueChange | (value, eventDetails) => void | No default | Called with the new value, or null when cleared. |
multiple | boolean | false | Allows several values, usually rendered as chips. |
itemToStringLabel | (item: Value) => string | No default | The text for an object item, shown in the input and used by the filter. |
isItemEqualToValue | (item: Value, value: Value) => boolean | No default | Compares items to the value when they aren't the same objects. |
filter | ((item, query) => boolean) | null | No default | Replaces the default filter. null turns it off, for server search. |
inputValue / onInputValueChange | string / (value: string) => void | No default | Control the text, for example to search a server. |
open / onOpenChange | boolean / (open: boolean) => void | No default | Control the popup. |
autoHighlight | boolean | false | Highlights the first match as people type. |
disabled | boolean | false | Disables the whole control. |
ComboboxInput
The text field, an Input group with the chevron trigger and an optional clear button. className goes to the group.
Other props spread onto Base UI Combobox.Input.
| Prop | Type | Default | Description |
|---|---|---|---|
showTrigger | boolean | true | Shows the chevron button that opens the list. |
showClear | boolean | false | Shows a clear button in place of the chevron once there is a value. |
disabled | boolean | false | Disables the input and its buttons. |
placeholder | string | No default | What to type, such as Search people. |
children | ReactNode | No default | Extra addons rendered inside the group. |
ComboboxContent
The portal, positioner and popup.
Other props spread onto Base UI Combobox.Popup.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "bottom" | "left" | "right" | "inline-start" | "inline-end" | "bottom" | Preferred side. It flips to stay in view. |
sideOffset | number | 6 | Gap from the field in px. |
align | "start" | "center" | "end" | "start" | Alignment along the field. |
alignOffset | number | 0 | Offset along the alignment axis. |
anchor | RefObject<HTMLDivElement | null> | No default | The element to position against. Pass the useComboboxAnchor() ref used on ComboboxChips. |
ComboboxList
The scrolling list. Pass a function child (item, index) => ReactNode to render filtered items.
Other props spread onto Base UI Combobox.List.
No props of its own.
ComboboxItem
A row with the check indicator.
Other props spread onto Base UI Combobox.Item.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | Value | No default | The item this row picks. |
disabled | boolean | false | Shown at 50% and skipped by the highlight. |
ComboboxGroup
A group of rows. Pass the group's items for ComboboxCollection.
Other props spread onto Base UI Combobox.Group.
| Prop | Type | Default | Description |
|---|---|---|---|
items | readonly Item[] | No default | The group's items after filtering. |
ComboboxLabel
A 12px Slate Meta group heading.
Other props spread onto Base UI Combobox.GroupLabel.
No props of its own.
ComboboxCollection
Renders a group's items with a function child.
Other props spread onto Base UI Combobox.Collection.
No props of its own.
ComboboxEmpty
The message shown when nothing matches.
Other props spread onto Base UI Combobox.Empty.
No props of its own.
ComboboxSeparator
A hairline between groups.
Other props spread onto Base UI Combobox.Separator.
No props of its own.
ComboboxChips
The field for multiple: a wrapping box that holds chips and the chips input, with the focus and invalid rings.
Other props spread onto Base UI Combobox.Chips.
| Prop | Type | Default | Description |
|---|---|---|---|
ref | RefObject<HTMLDivElement | null> | No default | Pass the useComboboxAnchor() ref. |
ComboboxChip
One chosen value with a remove button.
Other props spread onto Base UI Combobox.Chip.
| Prop | Type | Default | Description |
|---|---|---|---|
showRemove | boolean | true | Shows the remove button. |
ComboboxChipsInput
The text input inside the chips box, at least 64px wide.
Other props spread onto Base UI Combobox.Input.
No props of its own.
ComboboxValue
Renders the current value. With chips, pass a function child (values) => ReactNode.
Other props spread onto Base UI Combobox.Value.
No props of its own.
ComboboxTrigger
A button that opens the list, with a chevron appended. ComboboxInput renders one for you.
Other props spread onto Base UI Combobox.Trigger.
No props of its own.
useComboboxAnchor
Returns a RefObject<HTMLDivElement | null> to share between ComboboxChips and ComboboxContent.
No props of its own.
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The popup draws its edge with ring-1 plus shadow-md rather than the single composite overlay shadow the Hairline-and-Lift Rule asks for.
ComboboxChip is 21px with 6px corners, off the tag ramp (20px, 8px corners). Chips aren't tags, but the size isn't on any ramp.
The chip remove button has no accessible name. It is out of the tab order, so keyboard users remove chips with Backspace, but screen reader users browsing the field meet an unnamed button.
ComboboxClear isn't exported. Clearing is only available through showClear on ComboboxInput, so the chips field has no clear-all button.
Three product files use it, all in team settings, so its patterns are less proven than Select's.