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
Example
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
Example
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
Example
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,defaultValueandonChange. React Aria's olderselectedKey,defaultSelectedKeyandonSelectionChangeare not accepted. - Controlled input: if you also control
inputValue, as a server search does, empty it in youronChange. 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 |
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
|
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
gridnamed "Selected" plus the label (or the field'saria-labeloraria-labelledbywhen 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-Z | Filters the list as you type. |
|---|---|
| DownUp | Open the list with every option, then move through it. |
| Enter | Toggles the active option; the list stays open and the input empties. |
| Escape | Closes the list and empties the input. |
| Tab | Closes the list and moves on; the value is unchanged. |
| Backspace | In an empty input, closes the list and focuses the last tag; again, removes it. |
| BackspaceDelete | On a tag, remove it; focus goes to its neighbor, or the input. |
| LeftRight | Move 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.