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
Example
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 fromonChangeand 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
Example
'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
Example
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,defaultValueandonChange. React Aria's deprecatedselectedKey,defaultSelectedKeyandonSelectionChangeare not accepted. - Clearing: emptying the field clears the value, and
onChangereportsnull. If you control bothvalueandinputValue, clear it yourself inonInputChange. - Free text: leaving the field, or scrolling the page while the list is open, puts the chosen option's text back. Pass
allowsCustomValueto keep free text instead, which a form then submits. - Forms: give it a
nameto submit the chosen key, or its text withformValue="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 |
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 |
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-Z | Filters the list as you type. |
|---|---|
| DownUp | Open the list with every option, then move through it, stopping at the ends. |
| HomeEndPageUpPageDown | Move through the open list. |
| Enter | Chooses the active option and closes. |
| Escape | Closes the list and puts the chosen text back. |
| Tab | Chooses 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.