Choose several values from a filterable list, shown as removable tags and sent with a form.

Basic

Type part of a name to list the options that contain it, or open every option with the button or the down arrow. A press or Enter toggles an option. The list stays open for the next choice, the input empties for the next search, and each chosen row shows a check. Chosen values show in the field as tags, in the order chosen.

Example

Changed
Type to filter, and choose as many as you need.
Follows the page theme and corners
import { MultiSelect, PickerOption } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

const CATEGORIES = [
  'Food',
  'Rent',
  'Utilities',
  'Transport',
  'Health',
  'Insurance',
  'Fun',
  'Travel',
  'Gifts',
  'Education',
  'Savings',
  'Subscriptions',
];

export default function MultiSelectBasic(): ReactElement {
  return (
    <MultiSelect
      label="Categories"
      description="Type to filter, and choose as many as you need."
      placeholder="Add a category"
      style={{ width: '100%', maxWidth: '24rem' }}
    >
      {CATEGORIES.map((name) => (
        <PickerOption key={name} id={name.toLowerCase()}>
          {name}
        </PickerOption>
      ))}
    </MultiSelect>
  );
}

Many values

The field grows a row at a time, so every chosen value stays in view. Nothing collapses into "and 3 more". Its corner is a full pill on one row and the surface step once it wraps. A tag label longer than 12rem ends in an ellipsis, and its full text stays the tag's name.

Example

Changed
Food
Rent
Utilities
Transport
Health
Fun
Travel
Subscriptions and memberships we keep meaning to cancel
Every chosen line shows; the field grows a row at a time.
Follows the page theme and corners
import { MultiSelect, PickerOption } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

const CATEGORIES = [
  'Food',
  'Rent',
  'Utilities',
  'Transport',
  'Health',
  'Insurance',
  'Fun',
  'Travel',
  'Gifts',
  'Education',
  'Savings',
  'Subscriptions and memberships we keep meaning to cancel',
];

const idOf = (name: string): string => name.toLowerCase().split(' ')[0] ?? name;

export default function MultiSelectMany(): ReactElement {
  return (
    <MultiSelect
      label="Budget lines"
      description="Every chosen line shows; the field grows a row at a time."
      defaultValue={['food', 'rent', 'utilities', 'transport', 'health', 'fun', 'travel', 'subscriptions']}
      style={{ width: '100%', maxWidth: '24rem' }}
    >
      {CATEGORIES.map((name) => (
        <PickerOption key={name} id={idOf(name)}>
          {name}
        </PickerOption>
      ))}
    </MultiSelect>
  );
}

Removing

Each tag has a remove button. From the keyboard, the tags are one tab stop before the input.

  • Arrow keys move between tags.
  • Backspace or Delete removes the focused tag. Focus moves to the next tag, the previous one if it was last, or the input if none are left.
  • Backspace in an empty input focuses the last tag, and a second press removes it. One key never removes a value you did not see.
  • While the list is open, a remove button still works and closes the list. Backspace in the empty input closes the list, then focuses the last tag.

States

  • Changed: shows the Changed chip and the light signal orange edge until the values are saved.
  • Invalid: shows the error edge and its error line.
  • Disabled: recessed, with taupe tags and no remove buttons. Nothing in it takes focus.
  • Read-only: tags stay reachable by keyboard for reading, with no remove buttons and no removal.

Example

Changed
Food
Travel
Choose at least one category.
Changed
Gifts
Rent
Follows the page theme and corners
import { MultiSelect, PickerOption } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

const OPTIONS = (
  <>
    <PickerOption id="food">Food</PickerOption>
    <PickerOption id="rent">Rent</PickerOption>
    <PickerOption id="travel">Travel</PickerOption>
    <PickerOption id="gifts">Gifts</PickerOption>
  </>
);

export default function MultiSelectStates(): ReactElement {
  return (
    <div className="stack" style={{ width: '100%', maxWidth: '24rem' }}>
      <MultiSelect label="Categories" defaultValue={['food', 'travel']} isChanged>
        {OPTIONS}
      </MultiSelect>
      <MultiSelect
        label="Budget lines"
        placeholder="Add a category"
        isInvalid
        errorMessage="Choose at least one category."
      >
        {OPTIONS}
      </MultiSelect>
      <MultiSelect label="Archived categories" defaultValue={['gifts', 'rent']} isDisabled>
        {OPTIONS}
      </MultiSelect>
    </div>
  );
}

Usage

The value is readonly Key[], and onChange receives Key[]. Keep it in the URL so a filtered view can be shared, reloaded and returned to. Read a repeated parameter with searchParams.getAll('category'), or split a comma-joined one, and write it back in onChange. With the Next App Router:

'use client';

import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { MultiSelect, PickerOption } from '@snapshot-ui/react';

export function CategoryFilter({ categories }: { categories: { id: string; name: string }[] }) {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  return (
    <MultiSelect
      label="Categories"
      defaultItems={categories}
      value={searchParams.getAll('category')}
      onChange={(keys) => {
        const params = new URLSearchParams(searchParams);
        params.delete('category');
        for (const key of keys) params.append('category', String(key));
        const query = params.toString();
        // No bare "?" once the last filter goes, and no jump to the top: the reader stays at the field.
        router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
      }}
    >
      {(category) => <PickerOption>{category.name}</PickerOption>}
    </MultiSelect>
  );
}
  • Value props: use value, defaultValue and onChange. React Aria's older selectedKey, defaultSelectedKey and onSelectionChange are not accepted.
  • Controlled input: if you also control inputValue, as a server search does, empty it in your onChange. React Aria empties the input after a choice only while one of the two is uncontrolled.
  • Forms: give it a name, and each chosen key is submitted as its own value under that name.
  • Tag text: a tag shows its option's text value, so an option whose label is not a string needs a textValue.
  • No allowsCustomValue: a multi-select chooses from its list. Free text is a tag input's job.

Props

Every React Aria ComboBox prop of its multiple mode is accepted, except the deprecated selection names and allowsCustomValue. These are the ones Snapshot UI adds or shapes. The options, PickerOption and PickerSection, are documented on the select page.

allowsEmptyCollection
boolean

Whether the list stays open when nothing matches, showing the no-matches line, or the loading line while options load, instead of closing.

Default: true

aria-label
string

Defines a string value that labels the current element.

changedLabel
string

The text of the Changed chip.

Default: 'Changed'

className
string | ((values: ComboBoxRenderProps & { defaultClassName: string | undefined; }) => string)

A class name, or a function of the field's state, merged after the library's own classes.

defaultValue
readonly Key[]

The default value (uncontrolled).

description
ReactNode

A hint under the field. It becomes the input's accessible description.

errorMessage
((validation: ValidationResult) => ReactNode) | ReactNode

The error line, shown only while the field is invalid. A function receives the validation result. Without it the field shows its own validation messages.

inputValue
string

The value of the ComboBox input (controlled).

isChanged
boolean

Whether the value differs from the saved one. Shows the clay Changed chip in the label row and the light signal orange edge. A screen reader reads the chip with the input. An invalid field shows its error instead.

isDisabled
boolean

Whether the input is disabled.

isInvalid
boolean

Whether the input value is invalid.

isLoading
boolean

Shows the Spinner in the field while the app loads options. The list keeps what it has.

isReadOnly
boolean

Whether the input can be selected but not changed by the user.

items
Iterable<object>

The list of ComboBox items (controlled).

label
ReactNode

The field's name, shown above it. Required unless the field has an aria-label or an aria-labelledby.

labels
Partial<MultiSelectLabels>

The library's own words: the button's name, the list's lines, the tags' name and the remove buttons.

name
string

The name of the input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname).

onChange
((value: Key[]) => void)

Handler that is called when the value changes.

onInputChange
((value: string) => void)

Handler that is called when the ComboBox input value changes.

placeholder
string

The input's placeholder, shown only while nothing is chosen. Taupe under a visible label. Slate when only an aria-label or aria-labelledby names the field, since it is then the only visible text.

placement
Placement

Where the list opens.

Default: 'bottom start'

popoverClassName
string

Goes on the popover around the list.

value
readonly Key[]

The current value (controlled).

empty
string

The line an open list shows when nothing matches.

Default: 'No matches'

loading
string

The Spinner's name, and the line an empty list shows while options load.

Default: 'Loading'

open
string

The button's name.

Default: 'Show options'

remove
string

Each tag's remove button; React Aria reads it with the tag's text: "Remove Food".

Default: 'Remove'

selected
string

The name of the group of chosen values, read with the field's label: "Selected Categories".

Default: 'Selected'

Accessibility

The input is a combobox with aria-expanded and aria-controls, over a multi-select listbox of options named by the field's label. Each chosen row has a check as well as its selected state.

  • Tags: a grid named "Selected" plus the label (or the field's aria-label or aria-labelledby when no label is shown), one tab stop before the input.
  • Remove buttons: each is named "Remove" plus the tag's text. They stay out of the tab order, since Backspace and Delete do the same on a focused tag.
  • Description: the input is described by the chosen values, so a screen reader hears them after the field's name.
  • Open list: React Aria hides the rest of the page from assistive technology, tags included, but leaves them focusable. A remove button still works, and Shift+Tab still reaches the tags and closes the list.
  • Disabled: the tags are a disabled group, read but never focused.
  • Strings: React Aria's option count and its focused tag hint, "Press Delete to remove tag.", have no Persian strings. The field's names and lines come from the library's labels.

How it reads with VoiceOver on iOS and TalkBack is not yet verified.

A-ZFilters the list as you type.
DownUpOpen the list with every option, then move through it.
EnterToggles the active option; the list stays open and the input empties.
EscapeCloses the list and empties the input.
TabCloses the list and moves on; the value is unchanged.
BackspaceIn an empty input, closes the list and focuses the last tag; again, removes it.
BackspaceDeleteOn a tag, remove it; focus goes to its neighbor, or the input.
LeftRightMove between tags, in the drawn direction.

Design rules

What keeps multi-selects in the system.

Use a multi-select to choose several values from a known list.

Keep option labels short enough to read as tags.

Keep the value in the URL when it filters a view.

Never collapse the tags into a count inside the field. A filter chip summarizes in its own pill.

Never use a multi-select for free text.

Never draw a chosen value as a Badge.

esc

↑ ↓ to move↵ to open