A text field that filters a list as you type, or asks your server, and chooses one option.

Basic

A text field over a list. Type part of a name and the list shows the options that contain it, ignoring case and accents: "cafe" finds "Café Luna", and "co" finds "Corner Mart" and "Cobalt Coffee". The button at the field's end, or the down arrow, shows every option. Choose with Enter or a press, and the field shows the option's text.

Use a combo box over a select when the list is long, or when people know the option by name. The options are the select's PickerOption and PickerSection, with their hints, icons and end values.

Example

Changed
Type part of the name, or open the list.
Follows the page theme and corners
import { ComboBox, PickerOption } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

const MERCHANTS = [
  'Café Luna',
  'City Bikes',
  'Coastline Rail',
  'Cobalt Coffee',
  'Corner Mart',
  'Green Grocer',
  'Harbor Books',
  'Northwind Fuel',
  'Oak Pharmacy',
  'Pine Bakery',
  'River Cinema',
  'Sunrise Gym',
];

export default function ComboBoxBasic(): ReactElement {
  return (
    <ComboBox
      label="Merchant"
      description="Type part of the name, or open the list."
      placeholder="Type a merchant"
      style={{ width: '100%', maxWidth: '24rem' }}
    >
      {MERCHANTS.map((name) => (
        <PickerOption key={name} id={name.toLowerCase().replace(/[^a-z]+/g, '-')}>
          {name}
        </PickerOption>
      ))}
    </ComboBox>
  );
}

From a server

To search on your server, pass items and inputValue, fetch in onInputChange, and set isLoading while you wait. The field shows a spinner, and the list keeps its results until new ones arrive, or says "Loading" while it has none. React Aria does not filter items you control, so your server filters, ranks and caps them. The example waits 300ms and returns at most eight.

  • Keep the chosen item in items. React Aria finds it there to put its text back on Escape and on leaving the field. Hold it from onChange and pass it first, then the results without it.
  • A choice writes its text back through onInputChange, so skip the search when the text is the chosen option's.
  • Drop answers that arrive after a newer request. Abort the last fetch with an AbortController, or number requests and keep only the latest, as the example does.

Example

Changed
Searched on the server as you type.
Follows the page theme and corners
'use client';

import { ComboBox, PickerOption } from '@snapshot-ui/react';
import { useEffect, useRef, useState, type ReactElement } from 'react';

interface Merchant {
  id: string;
  name: string;
}

/** The app's merchants, which a real app would search on its server. */
const NAMES = [
  'Aster Florist',
  'Bay Pharmacy',
  'Birch Hardware',
  'Café Luna',
  'Canal Laundry',
  'City Bikes',
  'Coastline Rail',
  'Cobalt Coffee',
  'Corner Mart',
  'Cove Seafood',
  'Delta Optics',
  'Elm Street Deli',
  'Fern Garden Supply',
  'Fresh Market',
  'Granite Gym',
  'Green Grocer',
  'Harbor Books',
  'Hilltop Bakery',
  'Iris Nail Studio',
  'Juniper Tea House',
  'Kite Toys',
  'Lantern Cinema',
  'Maple Diner',
  'Meadow Vet Clinic',
  'Northwind Fuel',
  'Oak Pharmacy',
  'Orchard Juice Bar',
  'Pine Bakery',
  'Quarry Climbing',
  'Railway Records',
  'River Cinema',
  'Saffron Kitchen',
  'Sunrise Gym',
  'Tidewater Market',
  'Union Barber',
  'Velvet Tailors',
  'Willow Books',
  'Wren Stationery',
  'Yarrow Yoga',
  'Zephyr Cycles',
];

const MERCHANTS: Merchant[] = NAMES.map((name) => ({
  id: name.toLowerCase().replace(/[^a-z]+/g, '-'),
  name,
}));

/** The most results the app shows at once. */
const CAP = 8;

/** Text with case and accents aside, for the app's own matching. */
const fold = (text: string): string =>
  text
    .normalize('NFD')
    .replace(/\p{Diacritic}/gu, '')
    .toLowerCase();

/** The app's own search: names that contain the text, those that start with it first, capped. */
function search(text: string): Merchant[] {
  const query = fold(text.trim());
  return MERCHANTS.filter(({ name }) => fold(name).includes(query))
    .sort((a, b) => Number(fold(b.name).startsWith(query)) - Number(fold(a.name).startsWith(query)))
    .slice(0, CAP);
}

export default function ComboBoxAsync(): ReactElement {
  const [text, setText] = useState('');
  const [results, setResults] = useState<Merchant[]>(() => search(''));
  const [chosen, setChosen] = useState<Merchant | null>(null);
  const [isLoading, setLoading] = useState(false);
  const pending = useRef<ReturnType<typeof setTimeout>>(undefined);
  /** The latest request's number, so an answer to an older one is dropped. */
  const latest = useRef(0);
  useEffect(
    () => () => {
      clearTimeout(pending.current);
    },
    [],
  );

  // React Aria puts the chosen text back from the items, on Escape and on leaving the field, so the chosen
  // merchant stays among them whatever the last search found.
  const items = chosen ? [chosen, ...results.filter((merchant) => merchant.id !== chosen.id)] : results;

  const change = (value: string): void => {
    setText(value);
    clearTimeout(pending.current);
    const request = ++latest.current;
    // A choice writes its own text back through here; that is not a search.
    if (chosen !== null && value === chosen.name) {
      setLoading(false);
      return;
    }
    // The list keeps what it has while the next results load.
    setLoading(true);
    // A stand-in for a request to the server, 300ms away. With a real fetch, pass an AbortController's signal
    // and abort the last one here, or check the number below, so a slow answer never replaces a newer one.
    pending.current = setTimeout(() => {
      if (request !== latest.current) return;
      setResults(search(value));
      setLoading(false);
    }, 300);
  };

  return (
    <ComboBox<Merchant>
      label="Merchant"
      description="Searched on the server as you type."
      placeholder="Type a merchant"
      items={items}
      inputValue={text}
      onInputChange={change}
      onChange={(key) => {
        setChosen(items.find((merchant) => merchant.id === key) ?? null);
      }}
      isLoading={isLoading}
      style={{ width: '100%', maxWidth: '24rem' }}
    >
      {(merchant) => <PickerOption>{merchant.name}</PickerOption>}
    </ComboBox>
  );
}

States

  • No matches: the list stays open and says "No matches" instead of vanishing, which would read as a broken field.
  • Changed: the Changed chip and the light signal orange edge, until the value is saved.
  • Invalid: the error edge and its error line.
  • Disabled: recessed, with its text in taupe.

Example

Changed
Choose a merchant.
Changed
Follows the page theme and corners
import { ComboBox, PickerOption } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

const OPTIONS = (
  <>
    <PickerOption id="cafe-luna">Café Luna</PickerOption>
    <PickerOption id="corner-mart">Corner Mart</PickerOption>
    <PickerOption id="harbor-books">Harbor Books</PickerOption>
  </>
);

export default function ComboBoxStates(): ReactElement {
  return (
    <div className="stack" style={{ width: '100%', maxWidth: '24rem' }}>
      <ComboBox label="Merchant" defaultValue="corner-mart" isChanged>
        {OPTIONS}
      </ComboBox>
      <ComboBox label="Payee" placeholder="Type a merchant" isInvalid errorMessage="Choose a merchant.">
        {OPTIONS}
      </ComboBox>
      <ComboBox label="Archived merchant" defaultValue="harbor-books" isDisabled>
        {OPTIONS}
      </ComboBox>
    </div>
  );
}

Usage

Keep the choice in the URL, so a filtered view can be shared, reloaded and revisited. Pass the search parameter as value and write the URL in onChange. With the Next.js App Router:

'use client';

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

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

  return (
    <ComboBox
      label="Merchant"
      defaultItems={merchants}
      value={searchParams.get('merchant')}
      onChange={(key) => {
        const params = new URLSearchParams(searchParams);
        if (key === null) params.delete('merchant');
        else params.set('merchant', 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 });
      }}
    >
      {(merchant) => <PickerOption>{merchant.name}</PickerOption>}
    </ComboBox>
  );
}
  • Selection: use value, defaultValue and onChange. React Aria's deprecated selectedKey, defaultSelectedKey and onSelectionChange are not accepted.
  • Clearing: emptying the field clears the value, and onChange reports null. If you control both value and inputValue, clear it yourself in onInputChange.
  • Free text: leaving the field, or scrolling the page while the list is open, puts the chosen option's text back. Pass allowsCustomValue to keep free text instead, which a form then submits.
  • Forms: give it a name to submit the chosen key, or its text with formValue="text".

Props

Every React Aria ComboBox prop is accepted except the deprecated selection names. These are the ones Snapshot UI adds or shapes. PickerOption and PickerSection are documented on the select page.

allowsCustomValue
boolean

Whether the ComboBox allows a non-item matching input value to be set.

allowsEmptyCollection
boolean

Whether the list stays open when nothing matches, showing the no-matches or loading line instead of vanishing.

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.

defaultItems
Iterable<object>

The list of ComboBox items (uncontrolled).

defaultValue
Key | null

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 and the light signal orange edge, read 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.

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<PickerLabels>

The library's own words: the button's name, the no-matches line and the loading line.

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 | null) => 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

Example text while the input is empty. Taupe under a visible label; slate when only an aria-label or aria-labelledby names the field, since it is then the only words shown.

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).

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'

Accessibility

The input is a combobox with aria-expanded and aria-controls, over a listbox of options, named by the field's label. The button is named "Show options" plus the label. It stays out of the tab order, since the down arrow does the same. Without a visible label, name the field with aria-label; its placeholder is then slate, since it is the only words shown.

  • Focus: stays in the field while the active option moves with aria-activedescendant. VoiceOver on iOS follows it unevenly, and reading with VoiceOver and TalkBack is not yet verified.
  • Announcements: React Aria announces the option count and the choice in its own strings, which have no Persian, so they stay English on a Persian page. The button's name and the list's lines come from labels.
  • Hidden page: while the list is open, React Aria hides the rest of the page from assistive technology, the spinner too, so an empty list says "Loading" itself.
  • Scrolling: scrolling the page closes the list and puts the chosen text back, so the list never drifts from its field. The phone's soft keyboard resizes the page without closing it.
A-ZFilters the list as you type.
DownUpOpen the list with every option, then move through it, stopping at the ends.
HomeEndPageUpPageDownMove through the open list.
EnterChooses the active option and closes.
EscapeCloses the list and puts the chosen text back.
TabChooses the active option, closes the list and moves on.

Design rules

What keeps combo boxes in the system.

Use a combo box for a long list, or for a search your server answers.

Keep the list open with a no-matches line rather than letting it vanish.

Cap what your server sends, and say so in the hint if it matters.

Never use a combo box for a few options. Use a select, or a segmented control.

Never filter in the browser what your server already capped.

Never use the placeholder as the only label on a form.

esc

↑ ↓ to move↵ to open