Multi-select

A field that keeps every selection as a removable tag and opens a searchable option list. Options can carry a leading avatar or glyph, a second description line and extra search keywords, and can be grouped under headings — flat and grouped entries may be mixed in the same array.

tsx
import { MultiSelect } from "@pestoui/core"

Default

Uncontrolled — pass defaultValue to preselect. Backspace on the field peels off the last tag.

  • Team chat
  • Email

Option media & descriptions

leading takes an avatar, a glyph or a colour dot, and the same node is reused verbatim on the tag once the option is picked. description adds a second line on the row — it is wired through aria-describedby rather than folded into the label, because a list where every row announces as “Issue tracker, opens an issue on failure” is worse than useless. keywords add search terms beyond the label and the value.

  • Team chat
  • Docs

Grouped options

An entry with its own options array becomes a labelled group; a bare option stays loose. The two can be mixed in one list, and loose options keep their order under a single unlabelled heading — so a group can be introduced later without rewriting the array.

  • Tech lead

Overflow & sizes

maxDisplay caps the visible tags — the rest collapse into a single “+N” badge.

  • Team chat
  • Email
  • 2 more selected: Push, Webhook
  • Team chat
  • Email
  • 2 more selected: Push, Webhook

Controlled

Hold the array yourself — onValueChange fires with the complete next selection.

  • Team chat
  • Email

Selected: chat, email

Search, select all & clear

showSelectAll puts Select all and Clear at the top of the list as real rows rather than in a footer — a footer sits outside the listbox, so the arrow keys never reach it. With a query live, Select all means every matching option: the row scores the query with the same filter the list uses, so the two can never disagree about what “matching” means. clearable adds the trailing button that empties the field in one action, and dismissing from either place keeps the list open so several can be dropped in a row. showSearch={false} removes the search box entirely and moves focus onto the list itself.

  • Team chat

Capping the selection

maxSelected is a hard ceiling. Once it bites, unpicked rows go disabled while the ones already chosen stay live, so a full field can still be emptied from the list rather than only from the tags. The message under the list is rendered as soon as a cap exists and left empty until it applies — a live region that mounts holding its text is the one screen readers stay silent about.

  • Team chat
  • Email

Disabled options, disabled field & invalid

A single option opts out with disabled; the whole field opts out with the prop of the same name, which also blocks the popover and every tag’s remove button. aria-invalid paints the destructive border and ring — wrap the field in Field and it is wired for you, along with the message id.

Pick at least one channel.

  • Team chat
  • Email

Props

PropTypeDefaultDescription
optionsMultiSelectItems—The list. An entry with its own options array is a group; anything else is a loose option, and the two can be mixed.
options[].leadingReact.ReactNode—Avatar, glyph or dot on the option row, reused verbatim on the tag. A bare img or svg is sized for you; anything else passes through at its own size.
options[].descriptionstring—Second line under the label, exposed through aria-describedby so it never becomes part of the row’s accessible name.
options[].keywordsstring[]—Extra search terms beyond the label and the value.
options[].disabledbooleanfalseTakes one row out of play without removing it from the list.
valuestring[]—Controlled selection — the values currently chosen.
defaultValuestring[][]Initial selection when the field is uncontrolled.
onValueChange(value: string[]) => void—Fires with the full next selection on toggle, remove, or backspace.
size"default" | "sm""default"Minimum field height — the tags and trigger shrink with it.
maxDisplaynumber—Tags past this count collapse into a single “+N” badge.
placeholderstring"Select options"Empty-state text, and the base of the field’s accessible name.
searchPlaceholderstring"Search options…"Placeholder for the search box inside the popover.
emptyMessageReact.ReactNode"No options found."Shown when the search matches nothing.
maxSelectednumber—Hard ceiling on the selection. Unpicked rows go disabled once it is hit; picked rows stay live so the field can still be emptied from the list.
maxSelectedMessagestring"You can select up to {maxSelected}."Shown, and politely announced, while the cap is blocking further picks.
showSearchbooleantrueRenders the search box inside the popover. With it off, focus lands on the list itself so the arrow keys still work.
showSelectAllbooleanfalseAdds Select all and Clear as rows above the list. With a query live, Select all covers every matching option.
selectAllLabelstring"Select all"Text on the select-all row.
clearLabelstring"Clear"Text on the clear row inside the list. Rendered only while something is selected.
clearablebooleanfalseAdds the trailing button that empties the field in one action. It trails the trigger rather than sitting inside it, since a button nested in a combobox is a nested-interactive violation.
clearAllLabelstring"Clear selection"Accessible name for that trailing button.
disabledbooleanfalseDims the field, blocks the popover, and disables every tag’s remove button.
aria-invalidboolean—Paints the destructive border and ring on the field. Field sets it for you when it has an error.

Pesto UI · 117 documented components