Developer Experience (DX)
Next, let's look at how developers use the Combobox. You give it the same list of options a Select takes and a name, put it in a Field, and bind its value only when your code needs to read it.
import { useState } from "react";
import { Combobox } from "@/components/Combobox";
import { Field } from "@/components/Field";
const countries = [
{ value: "fr", label: "France" },
{ value: "es", label: "Spain" },
{ value: "nz", label: "New Zealand" },
];
export function Shipping() {
const [country, setCountry] = useState<string>();
return (
<Field label="Country">
<Combobox id="country" name="country" options={countries} placeholder="Type a country" value={country} onChange={(event) => setCountry(event.target.value)} />
</Field>
);
}
Consistent
The Combobox takes the Select's names wherever the two do the same thing, so moving a field from one to the other is a change of component, not of props: options, multiple, placeholder, the same size scale as the Input, disabled, readonly, required, removeLabel and removedLabel, and the Select's slots, icon included. Like the Select, classes and styles go on the root, name and form on the hidden <select>, where change and input fire, and id, ARIA and autocomplete on the text field, since that's what a label and autofill point at.
| Name | Kind | What it means |
|---|---|---|
options | Prop | The Select's list of options, groups and separators |
multiple | Prop | Picks several values, each a tag, as on the Select |
size | Prop | xs, sm, md, lg or xl, as on the Input and the Select |
disabled | Prop | It can't be used, and leaves its form |
readonly | Prop | The value shows and submits, but can't change |
required | Prop | The form needs a value, which the Field sets |
placeholder | Prop | The text shown in the empty field |
name | Prop | The name the value submits under |
change | Event | The value changed |
The names the Select doesn't have are the ones for what only typing brings: allowCustomValue and creatable for typed values, filter for how options match, and emptyLabel, resultsLabel, createLabel and toggleLabel for the text it shows and announces. There's no nativeOnTouch, since a native picker can't take typed text, and no value slot or countLabel, since the text field shows the picked option's label, which people edit as they type, and the tags show several values.
Type-safe
The props, the options, the value and every slot are typed, so your editor can autocomplete them and your type checker catches mistakes like size="medium". The options are the Select's own types, so one list feeds both components.
| Prop | Type | Default | Description |
|---|---|---|---|
options | SelectItem[] | Required. The options, groups and separators people pick from | |
value | string | string[] | The value, bound or not, as an array with multiple | |
multiple | boolean | false | Picks any number of values, each shown as a tag |
placeholder | string | Shown in the empty field, and never a value | |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | How big the Combobox is |
disabled | boolean | false | Disables the text field and the <select>, and leaves its form |
readonly | boolean | false | Keeps the value in its form, and doesn't open |
required | boolean | false | Asks for a value, and the Field sets it |
allowCustomValue | boolean | false | Keeps typed text as the value, for one value only |
creatable | boolean | false | Ends the list with an option that adds what people typed |
createLabel | (text: string) => string | `Create "${text}"` | The create option's label |
filter | (option: SelectOption, text: string) => boolean | Any part of the label, ignoring case and accents | Whether an option matches what people typed |
emptyLabel | string | "No results" | Shown and announced when nothing matches |
resultsLabel | (count: number) => string | `${count} results`, or "1 result" | Announced once typing pauses |
toggleLabel | string | "Suggestions" | The toggle button's name |
removeLabel | (label: string) => string | `Remove ${label}` | A tag's remove button's name |
removedLabel | (label: string) => string | `${label} removed` | What screen readers hear after a removal |
| Event | Payload | Description |
|---|---|---|
create | string | People picked the create option, with the text they typed |
change | Event | Fires from the <select>, and bubbles, once the value changes, never on every key |
input | Event | Fires from the <select>, and bubbles, right before change |
| Slot | Gets | Description |
|---|---|---|
leading | An icon before the text, hidden from assistive technologies | |
arrow | The chevron in the toggle | |
option | option, selected, active | How an option reads. The check mark stays beside it |
tag | option | How a picked value reads in its tag. The remove button stays |
group-label | label | How a group's heading reads |
separator | The rule between options or groups | |
icon | option | An option's icon, in the panel, the field and its tag |
React and Solid take groupLabel for the group-label slot and onCreate for the event, and Angular binds (create), each in its own idiom. Import the types from the Select, where they live, so a list typed for one works for both:
import type { SelectItem } from "@/components/Select";
import type { ComboboxProps } from "@/components/Combobox";
const countries: SelectItem[] = [
{ value: "fr", label: "France" },
{ value: "nz", label: "New Zealand" },
];
Composable
Put the Combobox in a Field, and it gets its label, hint, error, required, disabled and size without any wiring. It takes the same text-control path through the Field as an Input and a Select do.
The slots let you change what's inside each part without giving up how it behaves. The option slot keeps the check mark beside what you render, and the tag slot keeps the remove button. Here's an option that shows the country's code beside its name:
<Combobox id="country" name="country" options={countries}
leading={<GlobeIcon />}
option={({ option, selected }) => <>{option.label} <small>{option.value.toUpperCase()}</small>{selected && " (yours)"}</>}
/>
Controllable
Bind the value when your code reads it, and leave it unbound when the form is all you need. The <select> holds it either way, so an unbound Combobox still submits and resets.
// Controlled: your state decides.
<Combobox id="country" name="country" options={countries} value={country} onChange={(event) => setCountry(event.target.value)} />
// Uncontrolled: the Combobox keeps its own, and its form submits it.
<Combobox id="country" name="country" options={countries} defaultValue="fr" />
// New values: add them to your list as people create them.
<Combobox id="topics" name="topics" multiple creatable options={topics} onCreate={(text) => setTopics([...topics, { value: text, label: text }])} />
change and input fire from the <select> when the value changes, not on every key, so a listener on the Combobox or on its form hears picks, typed values once they're kept and removals, with the value already updated. Listen for create to add a new value to your list, so people find it again next time. A ref gets you the text field and the <select>, through control and select, for focusing it from your own code.
DX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
combobox/select-names | Should | Component | It takes the Select's names for what the two share: options, multiple, size, placeholder, disabled, readonly, required and the slots | Open Components | Review |
combobox/typed | Must | Component | Its props, events and slots are typed, and it takes the Select's exported option types | Open Components | Type check |
combobox/value | Must | Component | The value is a string, or an array with multiple, bound or unbound from a value attribute, and the text follows a bound value as it changes | Open Components | Unit test |
combobox/native-events | Should | Component | change and input fire from its <select> when the value changes, never on every key | HTML | Unit test |
combobox/attributes | Must | Component | Classes and styles go on the root, name and form on the <select>, and id, ARIA and autocomplete on the text field | Open Components | Unit test |
combobox/in-a-field | Must | Both | In a Field, it takes the label, description, invalid and disabled state | WCAG 1.3.1 (A) | Unit test |
combobox/slots | Should | Component | Each part has a slot or tokens to restyle it, and the check mark and remove button stay | Open Components | Unit test |
combobox/create-event | Must | Component | Picking the create option fires create with the text people typed | Open Components | Unit test |
combobox/custom-filter | Should | Component | A filter prop replaces how options match what people typed | Open Components | Unit test |
combobox/translatable | Should | Component | Every string it shows or announces comes through a prop | Open Components | Unit test |
combobox/exposed | Should | Component | A ref gets the text field and the <select> | Open Components | Unit test |
FAQ
freeSolo and Fluent UI freeform, which you'd have to look up, while Ark UI's allowCustomValue, which React Aria spells allowsCustomValue, reads as what it allows. We took Ark UI's spelling, since our boolean props are adjectives and verbs without a leading "s", like multiple and creatable.User Experience
How a combobox behaves for every person, input and setting, from the ARIA pattern and the keyboard to filtering, typed values, forms and the on-screen keyboard.
Agentic Experience
How agents read, operate and check a combobox, from its accessibility tree to its contract, its DOM and its tests.