Skip to content

Multi-select

Choose several values from a searchable list and show them as compact chips.

import { MultiSelect, MultiSelectOption } 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 a multi-select to pick several items from a known list: members of a project, labels on an issue, regions to deploy to. The values stay visible as chips in the field. For one value from the same list, use a Combobox. For values people make up, such as topics or email domains, use a Tag Input. A few options that all fit on screen read better as Checkboxes.

import { useState } from 'react'
import { Field, FieldLabel } from '@youj/react'
import { MultiSelect } from '@youj/react/command'

const people = [
  { value: 'ada', label: 'Ada Lovelace', group: 'Engineering' },
  { value: 'grace', label: 'Grace Hopper', group: 'Engineering' },
  { value: 'dieter', label: 'Dieter Rams', group: 'Design' },
]

export function MembersField() {
  const [members, setMembers] = useState<string[]>(['ada'])
  return (
    <Field>
      <FieldLabel>Members</FieldLabel>
      <MultiSelect options={people} value={members} onValueChange={setMembers} placeholder="Add members" />
    </Field>
  )
}

The field is an Input Group frame that holds the chosen values as Badge chips, each with its own remove button. The trigger beside them has role="combobox" and opens a Command of checkbox rows in a Popover. Enter toggles a row and the popup stays open, so several values can be picked in one go. Options take the same shape as the Combobox's: value, label, and optionally description, icon, group, keywords and disabled.

A "Select all" row leads the list when you aren't searching. Over a partial selection it shows a mixed state. Turn it off with selectAll={false}.

Reference

Propsoptions, value, defaultValue… (18)

Props

MultiSelect props
PropType
optionsRequiredMultiSelectOption[]–
valuestring[]–
defaultValuestring[]

default []

[]
onValueChange(value: string[]) => void–
placeholderstring

Shown 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."
maxCountnumber

The most values that can be chosen; further rows disable once it's reached.

–
selectAllboolean

Shows a "Select all" row (when not searching, and when the limit allows all).

default true

true
disabledboolean–
namestring

Also submits each value with a native form, under this name.

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

Classes for the popup (its width follows the field 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–

MultiSelectOption 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 MultiSelect 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 265 ms, and clearing the search about 0.05 s where it took 0.36 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: MultiSelect has no async search; narrow the list before passing it (a first-level filter, a scoped query). "Select all" selects every option, shown or not.
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, a search field and a Done button at the top, and 48px checkbox rows. A tap toggles a row and the sheet stays open; Done, pulling the sheet down, tapping outside it or Escape closes it, and focus returns to the field. The sheet opens with the on-screen keyboard down. Tap the search field to type: the sheet sits on top of the keyboard with the field in view, and it keeps its height as results narrow.

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.

MotionChip enters, chip leaves, chips close the gap… · reduced motion

Motion

Motion tokens
MomentTokens
Chip entersfades down into its slot while the others glide aside (PresenceList)
duration.baseease.enter

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

220mscubic-bezier(0.16, 1, 0.3, 1)
Chip leavesfades where it stands
duration.fastease.exit

140ms · cubic-bezier(0.4, 0, 1, 1)

140mscubic-bezier(0.4, 0, 1, 1)
Chips close the gap
spring.smooth

0.36s, bounce 0

0.36s, bounce 0
Openthe popup grows out of the field (yj-pop)
duration.fastease.enter

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

140mscubic-bezier(0.16, 1, 0.3, 1)
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
Checka row's checkbox draws its mark
duration.slowease.emphasized

340ms · cubic-bezier(0.6, 0, 0.1, 1)

340mscubic-bezier(0.6, 0, 0.1, 1)

Reduced motion

Chips appear, disappear and reflow in one frame. The popup opens at once, the highlight jumps and the chevron flips in place.

Reduced motion

Chips are added, removed and reflowed without fading or gliding, and the popup, the chevron and the active row change at once. Selection, 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. The Field's description and error describe it.
  • ArrowDown, Enter or Space opens the list. In the list, Enter toggles a row and Escape closes. Backspace in an empty search removes the last value.
  • On the trigger, Backspace or ArrowLeft moves to the last chip. Arrows move between chips, Backspace or Delete removes the focused chip, and focus lands on its neighbor, so a keyboard user never falls off the field.
  • Each chip's remove button is named after its value. Removals, "Select all" and reaching the limit are announced.
  • 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.
  • With name, each value is submitted with a native form under that name.