A field for choosing one value from a list, with a filter on longer lists.
Basic
Pass a label, an optional hint, and the options as PickerOptions. Until one is chosen, the field shows "Choose an option" or your placeholder. Use a select for one value from a list known in advance. For a few short options that fit side by side, use a segmented control instead.
Example
Example
import { PickerOption, Select } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function SelectBasic(): ReactElement {
return (
<Select
label="Category"
description="Where this transaction goes in the budget."
style={{ width: '100%', maxWidth: '24rem' }}
>
<PickerOption id="groceries">Groceries</PickerOption>
<PickerOption id="rent">Rent</PickerOption>
<PickerOption id="transport">Transport</PickerOption>
<PickerOption id="utilities">Utilities</PickerOption>
<PickerOption id="dining">Dining out</PickerOption>
</Select>
);
}Rows
Options use the menu's row:
icon: shown at the start.description: a hint under the label, for options that need telling apart.end: a preformatted value at the end, such as this month's total.
Group options with PickerSection, whose title is an overline heading. The trigger shows only the chosen option's icon and label. A string label is also the option's text value, used by typeahead, the filter and the trigger. A label that is not a string needs a textValue.
Example
Example
import { PickerOption, PickerSection, Select } from '@snapshot-ui/react';
import { Briefcase, Car, House, ShoppingCart, Undo2 } from 'lucide-react';
import type { ReactElement } from 'react';
export default function SelectDetails(): ReactElement {
return (
<Select label="Category" defaultValue="groceries" style={{ width: '100%', maxWidth: '24rem' }}>
<PickerSection title="Spending">
<PickerOption
id="groceries"
icon={<ShoppingCart />}
description="Supermarkets and markets"
end="$1,204.50"
>
Groceries
</PickerOption>
<PickerOption id="rent" icon={<House />} end="$2,100.00">
Rent
</PickerOption>
<PickerOption id="transport" icon={<Car />} description="Fuel, fares and parking" end="$318.20">
Transport
</PickerOption>
</PickerSection>
<PickerSection title="Income">
<PickerOption id="salary" icon={<Briefcase />} end="$6,400.00">
Salary
</PickerOption>
<PickerOption id="refunds" icon={<Undo2 />}>
Refunds
</PickerOption>
</PickerSection>
</Select>
);
}Filtering
With nine or more options, the list opens with a filter field at its top. Pass isFilterable to show it on a shorter list, or isFilterable={false} to hide it on a longer one.
- An option matches when every word typed appears in it, in any order, ignoring case and accents. "cafe" finds "Café" and "rent home" finds "Home rent".
- A section with no matches hides, and "No matches" shows when nothing matches.
- The field is empty each time the list opens.
- On a phone the filter does not take focus on open, so the keyboard does not cover the list. Tap the field to filter.
Example
Example
import { PickerOption, PickerSection, Select } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function SelectFilter(): ReactElement {
return (
<Select label="Category" defaultValue="rent" style={{ width: '100%', maxWidth: '24rem' }}>
<PickerSection title="Spending">
<PickerOption id="groceries">Groceries</PickerOption>
<PickerOption id="rent">Home rent</PickerOption>
<PickerOption id="transport">Transport</PickerOption>
<PickerOption id="utilities">Utilities</PickerOption>
<PickerOption id="dining">Dining out</PickerOption>
<PickerOption id="cafe">Café</PickerOption>
<PickerOption id="health">Health</PickerOption>
<PickerOption id="travel">Travel</PickerOption>
</PickerSection>
<PickerSection title="Income">
<PickerOption id="salary">Salary</PickerOption>
<PickerOption id="refunds">Refunds</PickerOption>
<PickerOption id="interest">Interest</PickerOption>
<PickerOption id="gifts">Gifts</PickerOption>
</PickerSection>
</Select>
);
}States
A changed select shows the Changed chip and the light signal orange edge until the value is saved. An invalid one shows the error edge and its error line. A disabled one is recessed, with its text and chevron in taupe.
Example
Example
import { PickerOption, Select } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
const OPTIONS = (
<>
<PickerOption id="groceries">Groceries</PickerOption>
<PickerOption id="rent">Rent</PickerOption>
<PickerOption id="transport">Transport</PickerOption>
</>
);
export default function SelectStates(): ReactElement {
return (
<div className="stack" style={{ width: '100%', maxWidth: '24rem' }}>
<Select label="Category" defaultValue="rent" isChanged>
{OPTIONS}
</Select>
<Select label="Budget category" isInvalid errorMessage="Choose a category.">
{OPTIONS}
</Select>
<Select label="Archived category" defaultValue="transport" isDisabled>
{OPTIONS}
</Select>
</div>
);
}Usage
Keep the choice in the URL so a filtered view can be shared, reloaded and reached with Back. Pass the search parameter as value and write the URL in onChange. With the Next App Router:
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { PickerOption, Select } from '@snapshot-ui/react';
export function CategoryFilter() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
return (
<Select
label="Category"
value={searchParams.get('category') ?? 'any'}
onChange={(key) => {
const params = new URLSearchParams(searchParams);
if (key === null || key === 'any') params.delete('category');
else params.set('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 });
}}
>
<PickerOption id="any">Any category</PickerOption>
<PickerOption id="groceries">Groceries</PickerOption>
<PickerOption id="rent">Rent</PickerOption>
</Select>
);
}- Use
value,defaultValueandonChange. React Aria's deprecatedselectedKey,defaultSelectedKeyandonSelectionChangeare not accepted. - A select has no clear action. When "any" is a real answer, add it as an option, as above.
- In a plain form, give it a
nameto submit the chosen key.
When the options come from data, pass them as items and render each with a function child. Each item needs an id or a key, which becomes the option's key:
<Select label="Account" items={accounts}>
{(account) => <PickerOption end={account.balance}>{account.name}</PickerOption>}
</Select>Props
Every React Aria Select prop is accepted except the deprecated selection props. These are the ones Snapshot UI adds or changes.
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: SelectRenderProps & { defaultClassName: string | undefined; }) => string) | A class name, or a function of the field's state, merged after the library's own classes. |
defaultValue Key | null | The default value (uncontrolled). |
description ReactNode | A hint under the field. It becomes the trigger's accessible description. |
disabledKeys Iterable<Key> | The item keys that are disabled. These items cannot be selected, focused, or otherwise interacted with. |
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. |
isChanged boolean | The value differs from the saved one. Shows the clay Changed chip and the light signal orange edge, and a screen reader reads the chip with the trigger. An invalid field shows its error instead. |
isDisabled boolean | Whether the input is disabled. |
isFilterable boolean | A filter field at the top of the list. Default: true at 9 options or more, false under |
isInvalid boolean | Whether the input value is invalid. |
isRequired boolean | Whether user input is required on the input before form submission. |
items Iterable<object> | The options as data, rendered by a function child. Omit it for static |
label ReactNode | The field's name, shown above it. Required unless the field has an |
labels Partial<SelectLabels> | The library's own words: the filter's name and the line shown when it keeps nothing. |
name string | The name of the input, used when submitting an HTML form. |
onChange ((value: Key | null) => void) | Handler that is called when the value changes. |
placeholder string | The trigger's text while nothing is chosen. Slate, or taupe while disabled. Default: 'Choose an option' |
placement Placement | Where the list opens. Default: 'bottom start' |
popoverClassName string | Goes on the popover around the list. |
value Key | null | The current value (controlled). |
children ReactNode | The label. A string is also the option's text value, for typeahead, filtering and the trigger. |
|---|---|
className string | ((values: ListBoxItemRenderProps & { defaultClassName: string | undefined; }) => string) | A class name, or a function of the option's state, merged after the library's own classes. |
description ReactNode | A hint under the label, in slate. It becomes the option's accessible description. |
end ReactNode | A preformatted value at the end, such as a count or an amount. Never formatted here. |
icon ReactNode | A 16px icon at the start, hidden from assistive technology. Shown in Select's trigger too. |
id Key | The unique id of the item. |
isDisabled boolean | Whether the item is disabled. |
textValue string | A string representation of the item's contents, used for features like typeahead. |
className string | |
|---|---|
items Iterable<object> | Item objects in the section. |
title ReactNode | The heading above the section's options, in the overline style. |
Accessibility
The trigger is a button with aria-haspopup="listbox", named by its value and label: a screen reader hears "Groceries, Category, button". It opens a listbox of options.
- An option is named by its label and described by its end value and hint.
- The chosen option has a check as well as the filled row, so the choice never relies on color alone.
- With a filter, focus stays in the field and the arrows move the active option. On a touch screen focus starts in the list.
- React Aria's own announcements, such as the list's name while filtered, use its own strings, which have no Persian.
| EnterSpaceDownUp | Open the list. |
|---|---|
| A-Z | Chooses the next option starting with the letter, without opening. |
| LeftRight | Choose the previous or the next option, without opening. |
| DownUpHomeEndPageUpPageDown | Move through the open list, stopping at the ends. |
| EnterSpace | Choose the option and close. |
| Escape | Clears the filter, then closes and returns focus. |
| Tab | Does not close the list; Escape closes it. |
Design rules
What keeps selects in the system.
Use a select for one value from a list known in advance.
Give options a hint where they need telling apart.
Pass amounts and counts preformatted, as the end value.
Never use a select for two or three options that fit side by side. Use a segmented control.
Never make an option that navigates. That is a menu item.
Never use the placeholder as the only label on a form.