Combobox
Choose a value from a searchable list, with groups and async results.
import { Combobox, ComboboxOption } from '@youj/react/command'
Installation & usage
npm i @youj/react cmdkFor an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.
Requires cmdk, an optional dependency. Import it from @youj/react/command.
Use Combobox to choose one value from a list that needs search, such as a directory of people. Use Multi-select for several values, or Tag Input for free-form entries. For short lists, use Select or Radio Group.
import { useState } from 'react'
import { Field, FieldLabel } from '@youj/react'
import { Combobox } from '@youj/react/command'
const regions = [
{ value: 'iad1', label: 'Washington, D.C.', group: 'Americas' },
{ value: 'fra1', label: 'Frankfurt', group: 'Europe' },
{ value: 'hnd1', label: 'Tokyo', group: 'Asia Pacific' },
]
export function RegionField() {
const [region, setRegion] = useState<string | null>('fra1')
return (
<Field>
<FieldLabel>Region</FieldLabel>
<Combobox options={regions} value={region} onValueChange={setRegion} placeholder="Choose a region" />
</Field>
)
}The trigger is a field-styled button with role="combobox", named by the FieldLabel around it. It opens a Popover holding a Command, so the search, the gliding active row and the list's height all behave like the command menu. Each option has a value and a label, and can add a description (searched too), an icon, a group heading, extra keywords and disabled. onValueChange hands you the value and the option.
Typing a letter on the closed trigger opens it with that letter already searched. With clearable, a clear button sits in the trigger, and Delete or Backspace clears the value from the keyboard.
Description
An owner field over nine people grouped by team, each with an avatar and an email, and a clear button.
Long labels truncate in the trigger and in the rows, and a row's description gives way before its name. The popup follows the trigger's width. Give it more room with contentClassName, for example w-80, when the trigger is narrow.
A reviewer field that searches a directory through a simulated 650 ms request and shows a loading row while it waits.
Pass onSearchChange and the built-in filtering turns off: fetch the matches yourself and pass them as options. While loading is true the list shows a loading row, which is announced, and the empty message waits until the results arrive.
Ignore responses from stale requests, as the example does, so a slow early request can't overwrite a newer one. The chosen option stays displayable in the trigger after it leaves options.
A framework field where typing a name that isn't listed offers to add it as a new option.
onCreate offers a "Create “…”" row for text that matches no option. Return the new option, or a promise of it, to select it. createLabel changes the row's wording.
Code
combobox-demo.tsx, the Demo story. The component itself comes from the package.
Reference
Propsoptions, value, defaultValue… (22)
Props
| Prop | Type | |
|---|---|---|
optionsRequired | ComboboxOption[] | – |
value | string | null | – |
defaultValue | string | nulldefault null | null |
onValueChange | (value: string | null, option: ComboboxOption | null) => void | – |
placeholder | stringShown in the trigger when nothing is chosen. default "Select…" | "Select…" |
searchPlaceholder | stringThe search field's placeholder and accessible name. default "Search…" | "Search…" |
emptyMessage | ReactNodeShown when no row matches. default "No results." | "No results." |
clearable | booleanShows a clear button (and lets Delete clear) while a value is chosen. default false | false |
onSearchChange | (search: string) => voidAsync search: called as the text changes. Built-in filtering turns off; pass the matching | – |
loading | booleanShows the loading row (async search). default false | false |
loadingLabel | stringAnnounced and shown while loading. default "Loading…" | "Loading…" |
onCreate | (label: string) => ComboboxOption | void | Promise<ComboboxOption | void>Offers a "Create" row for text that matches no option. Return (or resolve to) the new option to select it; the Combobox remembers it for display. | – |
createLabel | (search: string) => ReactNodeThe create row's label. default `Create “text”` | `Create “text”` |
disabled | boolean | – |
name | stringAlso submits the value with a native form. | – |
id | string | – |
ref | Ref<HTMLButtonElement> | – |
onBlur | () => void | – |
className | string | – |
contentClassName | stringClasses for the popup (its width follows the trigger by default). | – |
presentation | OverlayPresentation'auto''sheet''popover'
default 'auto' | 'auto' |
aria-label | string | – |
ComboboxOption is { value, label, description?, icon?, group?, keywords?, disabled? }.
Long listsPast 100 options only the best matches are mounted
Long lists
Every mounted row costs a registration, a score and a re-render on each keystroke, so past 100 options the Combobox mounts only the 100 best matches, in order of match, and ends them with a note ("Showing 100 of 2,000. Keep typing to narrow the results."). Searching reaches every option. With 2,000 options a keystroke takes about 35 ms where it took about 120 ms, and clearing the search about 0.07 s where it took 0.2 s (Chromium on a laptop, development build; 4x CPU throttling shows the same ratios).
- Up to 100 options: everything is mounted and cmdk filters, as before.
- 100 to about 5,000 options: pass them all. The filter runs on every keystroke over all options, which stays under a few milliseconds per thousand. Define the
optionsarray outside render, or memoise it, so unchanged rows aren't re-rendered. - More than that, or options that live on a server: use
onSearchChangeand pass the matches. Your results are cut to 100 rows too, so return the best 100 or fewer.
TouchOn a touch device the list opens as a bottom sheet with a grab…
Touch
On a touch device the list opens as a bottom sheet with a grab handle, the search field at the top and 48px rows. It opens with the list in view and the on-screen keyboard down; tap the field to search. While the keyboard is up, the sheet sits on top of it and is only as tall as the space that's left, so the field stays visible, and it keeps its height as results narrow so the field doesn't jump. A tap on a row picks it and closes the sheet. Pull the sheet down, tap outside it or press Escape to close it without picking, and focus returns to the trigger.
presentation chooses: auto (the default) is a popover under a mouse or trackpad and a sheet on touch, sheet always uses the sheet, and popover keeps the popover on touch too.
MotionOpen, close, keyboard open, Escape close… · reduced motion
Motion
| Moment | Tokens | ||
|---|---|---|---|
Openthe popup grows out of the trigger (yj-pop) | duration.fastease.enter140ms · cubic-bezier(0.16, 1, 0.3, 1) | 140mscubic-bezier(0.16, 1, 0.3, 1) | |
| Close | duration.instantease.exit80ms · cubic-bezier(0.4, 0, 1, 1) | 80mscubic-bezier(0.4, 0, 1, 1) | |
Keyboard open, Escape closeBase UI sets data-instant | no token 0ms | 0ms | |
| Chevronturns over while open | spring.snappy0.2s, bounce 0 | 0.2s, bounce 0 | |
| Active rowone indicator glides between rows | spring.smooth0.36s, bounce 0 | 0.36s, bounce 0 | |
| List heighteases as results come and go | duration.baseease.standard220ms · cubic-bezier(0.2, 0, 0, 1) | 220mscubic-bezier(0.2, 0, 0, 1) | |
| Chosen rowits check draws in | duration.slowease.emphasized340ms · cubic-bezier(0.6, 0, 0.1, 1) | 340mscubic-bezier(0.6, 0, 0.1, 1) |
Reduced motion
The popup opens and closes at once, the chevron flips without turning, the highlight jumps to the active row, the list snaps to its height and the check appears drawn.
Reduced motion
The popup appears without growing, the chevron flips in place, the active row's highlight jumps instead of gliding, and the list changes height at once. Search, keyboard handling and announcements are unchanged.
AccessibilityKeyboard, ARIA, focus
Accessibility
- The trigger has
role="combobox"and is named by itsFieldLabel, or byaria-labelwhen it has no label. The description and error of its Field describe it, andaria-invalidandaria-requiredfollow the Field. - ArrowDown, ArrowUp, Enter or Space opens the list. Typing a character opens it with that character searched. In the list, arrows move, Enter picks and returns focus to the trigger, and Escape closes.
- The chosen row carries
aria-checkedand a drawn check. Groups are labeled by their heading. - In a long list each row carries
aria-setsize(all matches) andaria-posinset, so assistive tech announces "3 of 2000" rather than "3 of 100"; the note under the rows is announced when the list opens and as the search changes. - Clearing, from the button or with Delete, is announced ("Selection cleared") and focus stays on the trigger. The clear button is named "Clear selection".
- The loading row is announced politely, and the empty message isn't shown until loading ends, so "No results" is never read out for results that are still coming.
- With
name, a hidden input submits the value with a native form.