Skip to content

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 cmdk

For 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.

Reference

Propsoptions, value, defaultValue… (22)

Props

Combobox props
PropType
optionsRequiredComboboxOption[]–
valuestring | null–
defaultValuestring | null

default null

null
onValueChange(value: string | null, option: ComboboxOption | null) => void–
placeholderstring

Shown in the trigger when nothing is chosen.

default "Select…"

"Select…"
searchPlaceholderstring

The search field's placeholder and accessible name.

default "Search…"

"Search…"
emptyMessageReactNode

Shown when no row matches.

default "No results."

"No results."
clearableboolean

Shows a clear button (and lets Delete clear) while a value is chosen.

default false

false
onSearchChange(search: string) => void

Async search: called as the text changes. Built-in filtering turns off; pass the matching options.

–
loadingboolean

Shows the loading row (async search).

default false

false
loadingLabelstring

Announced 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) => ReactNode

The create row's label.

default `Create “text”`

`Create “text”`
disabledboolean–
namestring

Also submits the value with a native form.

–
idstring–
refRef<HTMLButtonElement>–
onBlur() => void–
classNamestring–
contentClassNamestring

Classes for the popup (its width follows the trigger by default).

–
presentationOverlayPresentation
'auto''sheet''popover'

auto (the default) is a popover under a pointer and a bottom sheet on touch; force either one.

default 'auto'

'auto'
aria-labelstring–

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 options array outside render, or memoise it, so unchanged rows aren't re-rendered.
  • More than that, or options that live on a server: use onSearchChange and 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

Motion tokens
MomentTokens
Openthe popup grows out of the trigger (yj-pop)
duration.fastease.enter

140ms · cubic-bezier(0.16, 1, 0.3, 1)

140mscubic-bezier(0.16, 1, 0.3, 1)
Close
duration.instantease.exit

80ms · 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.snappy

0.2s, bounce 0

0.2s, bounce 0
Active rowone indicator glides between rows
spring.smooth

0.36s, bounce 0

0.36s, bounce 0
List heighteases as results come and go
duration.baseease.standard

220ms · cubic-bezier(0.2, 0, 0, 1)

220mscubic-bezier(0.2, 0, 0, 1)
Chosen rowits check draws in
duration.slowease.emphasized

340ms · 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 its FieldLabel, or by aria-label when it has no label. The description and error of its Field describe it, and aria-invalid and aria-required follow 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-checked and a drawn check. Groups are labeled by their heading.
  • In a long list each row carries aria-setsize (all matches) and aria-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.