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

CategoryChanged
Where this transaction goes in the budget.
Follows the page theme and corners
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

CategoryChanged
Follows the page theme and corners
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

CategoryChanged
Follows the page theme and corners
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

CategoryChanged
Budget category
Choose a category.
Archived categoryChanged
Follows the page theme and corners
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, defaultValue and onChange. React Aria's deprecated selectedKey, defaultSelectedKey and onSelectionChange are 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 name to 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 PickerOption children.

label
ReactNode

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

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.
EnterSpaceDownUpOpen the list.
A-ZChooses the next option starting with the letter, without opening.
LeftRightChoose the previous or the next option, without opening.
DownUpHomeEndPageUpPageDownMove through the open list, stopping at the ends.
EnterSpaceChoose the option and close.
EscapeClears the filter, then closes and returns focus.
TabDoes 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.

esc

↑ ↓ to move↵ to open