Autocomplete
The slotsmith Autocomplete is a React combobox and select in one component: it filters as you type, or works as a plain select with typeahead. It takes one value or several, remote options, a create row and virtual lists. Every part it renders is a slot you can replace.
Overview
The demo below uses the fallbacks only. Open a picker with the mouse or with ↓, type to filter, and pick with Enter. The topics picker keeps several values as tags; its last topic is archived and cannot be picked.
Country
Topics
Country: none
Topics: Accessibility, Forms
import { useId, useState } from "react";
import { Autocomplete, type OptionValue } from "slotsmith/autocomplete";
import { countries, topics, type Country } from "../shared/options";
export default function Overview() {
const countryLabel = useId();
const topicsLabel = useId();
const [country, setCountry] = useState<OptionValue | null>(null);
const [topicIds, setTopicIds] = useState<OptionValue[]>(["accessibility", "forms"]);
const countryName = countries.find((entry) => entry.code === country)?.name ?? "none";
const topicNames = topics.filter((topic) => topicIds.includes(topic.id)).map((topic) => topic.label);
return (
<div style={{ display: "grid", gap: "1rem", maxWidth: 360 }}>
<div>
<p id={countryLabel}>Country</p>
{/* One value, filtered as you type. */}
<Autocomplete<Country>
options={countries}
getOptionValue={(entry) => entry.code}
getOptionLabel={(entry) => entry.name}
value={country}
onChange={setCountry}
aria-labelledby={countryLabel}
/>
</div>
<div>
<p id={topicsLabel}>Topics</p>
{/* Several values, shown as tags; the archived topic cannot be picked. */}
<Autocomplete
multiple
options={topics}
optionDisabled={(topic) => !!topic.archived}
value={topicIds}
onChange={setTopicIds}
aria-labelledby={topicsLabel}
/>
</div>
<div>
<p>Country: {countryName}</p>
<p>Topics: {topicNames.length ? topicNames.join(", ") : "none"}</p>
</div>
</div>
);
}export type Country = { code: string; name: string };
/** Sorted by name, as a picker shows them. */
export const countries: Country[] = [
{ code: "ar", name: "Argentina" },
{ code: "au", name: "Australia" },
{ code: "br", name: "Brazil" },
{ code: "ca", name: "Canada" },
{ code: "eg", name: "Egypt" },
{ code: "fr", name: "France" },
{ code: "de", name: "Germany" },
{ code: "in", name: "India" },
{ code: "jp", name: "Japan" },
{ code: "jo", name: "Jordan" },
{ code: "ke", name: "Kenya" },
{ code: "mx", name: "Mexico" },
{ code: "ma", name: "Morocco" },
{ code: "nl", name: "Netherlands" },
{ code: "no", name: "Norway" },
{ code: "pt", name: "Portugal" },
{ code: "sa", name: "Saudi Arabia" },
{ code: "za", name: "South Africa" },
{ code: "es", name: "Spain" },
{ code: "se", name: "Sweden" },
{ code: "tr", name: "Türkiye" },
{ code: "ae", name: "United Arab Emirates" },
{ code: "gb", name: "United Kingdom" },
{ code: "us", name: "United States" },
];
/** `id` and `label` are the names the autocomplete reads by default, so topics need no getters. */
export type Topic = { id: string; label: string; archived?: boolean };
export const topics: Topic[] = [
{ id: "accessibility", label: "Accessibility" },
{ id: "animation", label: "Animation" },
{ id: "caching", label: "Caching" },
{ id: "design-systems", label: "Design systems" },
{ id: "forms", label: "Forms" },
{ id: "i18n", label: "Internationalisation" },
{ id: "performance", label: "Performance" },
{ id: "rtl", label: "Right-to-left" },
{ id: "server-components", label: "Server components" },
{ id: "state", label: "State" },
{ id: "testing", label: "Testing" },
{ id: "typescript", label: "TypeScript" },
{ id: "legacy", label: "Legacy browsers", archived: true },
];
/** A country with the region it is listed under; a `disabled` one stays in the list but cannot be picked. */
export type RegionCountry = Country & { region: string; disabled?: boolean };
/** A short list grouped by region. Groups show in the order of their first option, so the regions come in this order. */
export const countriesByRegion: RegionCountry[] = [
{ code: "de", name: "Germany", region: "Europe" },
{ code: "fr", name: "France", region: "Europe" },
{ code: "es", name: "Spain", region: "Europe" },
{ code: "ge", name: "Georgia", region: "Europe" },
{ code: "dz", name: "Algeria", region: "Africa" },
{ code: "eg", name: "Egypt", region: "Africa" },
{ code: "ng", name: "Nigeria", region: "Africa" },
{ code: "ne", name: "Niger", region: "Africa", disabled: true },
{ code: "jp", name: "Japan", region: "Asia" },
{ code: "sg", name: "Singapore", region: "Asia" },
{ code: "sa", name: "Saudi Arabia", region: "Asia" },
{ code: "sy", name: "Syria", region: "Asia" },
];Each feature has a guide with its own live demo: select or combobox with auto highlight, search in the trigger, multiple values, groups and separators, remote options with debounce, cancellation and paging, the loading, empty and error states, validation, creatable options, virtual lists for long lists, and the compound parts for your own layout.
Install
npm i slotsmithpnpm add slotsmithyarn add slotsmithbun add slotsmithVirtualAutocomplete from slotsmith/virtual also needs @tanstack/react-virtual.
import "slotsmith/autocomplete.css";Import the stylesheet once, at your app's entry. slotsmith/styles.css holds every component's styles instead.
Quick start
Pass options, value and onChange, and name the combobox with aria-label or aria-labelledby. Options with value and label (or id and name) need no further props.
import { useState } from "react";
import { Autocomplete, type OptionValue } from "slotsmith/autocomplete";
import "slotsmith/autocomplete.css";
// `value` and `label` are read by default; other shapes take getOptionValue and getOptionLabel.
const fruits = [
{ value: "apple", label: "Apple" },
{ value: "banana", label: "Banana" },
{ value: "cherry", label: "Cherry" },
{ value: "fig", label: "Fig" },
{ value: "mango", label: "Mango" },
{ value: "pear", label: "Pear" },
];
export default function QuickStart() {
const [fruit, setFruit] = useState<OptionValue | null>(null);
// aria-label names the combobox; style and className go on the root element.
return <Autocomplete options={fruits} value={fruit} onChange={setFruit} aria-label="Fruit" style={{ maxWidth: 320 }} />;
}Guides
- Select or comboboxUse the slotsmith Autocomplete as a combobox that filters as you type, or as a select with typeahead, by switching one prop.
- Search in the triggerPut the slotsmith Autocomplete's search box inside the trigger with searchIn="trigger": an editable combobox, its single and multiple modes, keys and IME.
- Multiple valuesPick several values with the slotsmith Autocomplete: tags in the trigger, Backspace to remove the last one, and an overflow count.
- Groups and separatorsGroup the slotsmith Autocomplete's options under labels with getOptionGroup: group order, groups that span pages, virtual lists and the ARIA roles.
- Remote optionsLoad the slotsmith Autocomplete's options from a server with useAsyncOptions: debounced, cancelled when superseded, and paged.
- Loading, empty and error statesWhat the slotsmith Autocomplete shows instead of options: the loading, empty, minimum-characters and error rows, and how to change them.
- ValidationShow a failed check on the slotsmith Autocomplete: the invalid prop, an error message tied to the combobox, and the label and message styles your app adds.
- Creatable optionsLet people add a missing option from the slotsmith Autocomplete's search: the create row, onCreate, and a pending create.
- Virtual listsShow thousands of options in the slotsmith Autocomplete with the virtual prop, which renders only the options in view.
- Compound partsLay out the slotsmith Autocomplete yourself with Autocomplete.Provider and its parts, read its state from the context, or use the useAutocomplete hook.
Reference
The API page is generated from the library’s source, so it lists every slot, prop and label the installed version has.
- SlotsEvery part you can replace, the props it receives and its fallback.
- PropsEvery prop, grouped, with its type and default.
- Labels & localesEvery string it shows or announces, and how to translate them.
- StylingIts class names and the tokens its stylesheet reads.
- AdaptersReady slot maps for shadcn/ui, MUI, Chakra, Ant Design and Radix Themes.
Adapters
The autocomplete works with any design system. Each slot takes a component, so a library’s own popover surface, input, tag and menu item can replace the fallbacks. The Adapters page has ready slot maps for shadcn/ui, MUI, Chakra, Ant Design and Radix Themes, as examples of well-known libraries; the same approach works with your own.
Accessibility
The autocomplete follows the ARIA combobox pattern, with the listbox in a popup:
- The trigger is the element with
role="combobox". It is in the tab order, and carriesaria-expanded,aria-haspopup="listbox"and, while open,aria-controlspointing at the list. The naming and validation attributes you pass toAutocomplete(aria-label,aria-labelledby,aria-describedby,aria-invalid,aria-requiredand the like, plusid) go to this element, not to the wrapper. - The search box gets focus when the popup opens. It has
role="searchbox",aria-autocomplete="list", and is named by thesearchlabel. See select or combobox. - Search in the trigger. With
searchIn="trigger"the combobox is an input inside the trigger, the WAI-ARIA 1.2 editable combobox. It carriesrole="combobox",aria-autocomplete="list",aria-expanded,aria-activedescendantand the naming props, keeps focus while the list is open, and a<label for>names it. The chevron is a button outside the tab order, named by thetogglelabel. See search in the trigger. - The highlight is announced through
aria-activedescendant: on the search box, or on the trigger itself whensearchableisfalse, so focus never leaves the field while you move through the options. - The list has
role="listbox", witharia-multiselectable="true"in multiple mode andaria-busy="true"whileloadingis set: withuseAsyncOptions, while each search loads its first page, even when the last options still show. Loading more at the end of the list does not set it. Each option hasrole="option"andaria-selected; an option ruled out byoptionDisabledhasaria-disabled="true"and the arrow keys skip it. See multiple values. - Groups. With
getOptionGroup, each group hasrole="group"and is named by its label througharia-labelledby. The line between groups is hidden from assistive technology. See groups and separators. - Invalid values.
invalidsetsaria-invalid="true"on the combobox, never on the popup’s search box. Point it at your error message witharia-describedby. See validation. - Result counts are read out: a visually hidden
role="status"region (aria-live="polite") says “12 results” as the list changes, from theresultslabel. It is empty while the popup is closed or shows a status row. See remote options. - Tags and clearing. Each tag’s remove button is named by the
removelabel (“Remove Forms”), and the clear button byclear. Spinners are hidden from assistive technology. See the states.
With focus on the closed trigger (the default, searchIn="popup"):
| Keys | Action |
|---|---|
| ↓EnterSpace | Open the popup and highlight the first option. |
| ↑ | Open the popup and highlight the last option. |
| A–Z | Open the popup. With searchable set to false, also jump to the first option that starts with the typed letters. |
With the popup open:
| Keys | Action |
|---|---|
| ↓↑ | Move the highlight, skipping disabled options. It stops at the ends unless loop is set. |
| HomeEnd | Highlight the first or the last option. |
| PgDnPgUp | Move the highlight ten options. |
| Enter | Pick the highlighted option. With nothing highlighted and a create row offered, create. |
| Esc | Close, and move focus back to the trigger. |
| Tab | Close, and move focus on as usual. |
| Backspace | In multiple mode, with the search box empty, remove the last tag. |
| A–Z | With searchable set to false: jump to the next option that starts with the typed letters. |
With searchIn="trigger" the input keeps the caret, so Space, Home and End stay in the text:
| Keys | Action |
|---|---|
| A–Z | Type into the search. The list opens and narrows. |
| ↓↑ | Open the list on the first or the last option; with the list open, move the highlight. |
| Alt+↓Alt+↑ | Open the list without moving the highlight, or close it. |
| PgDnPgUp | With the list open: move the highlight ten options. |
| Enter | Pick the highlighted option. Nothing is picked while an input method is composing. |
| Esc | Close the list and clear the search. In single mode the picked label comes back. |
| Backspace | In multiple mode, with the input empty, remove the last tag. |
Picking an option in single mode closes the popup and returns focus to the trigger; in multiple mode the popup stays open. A pointer press outside the control closes it. Every label can be translated or replaced; see Labels & locales.
Limitations
FAQ
Is the autocomplete a select or a combobox?
Both. By default it is a combobox: the popup has a search box that filters the options as you type. With searchable set to false it is a select: no search box, and typing a letter jumps to the first option that starts with it.
Does the autocomplete fetch options itself?
No. It renders the options you pass. For remote options, the useAsyncOptions hook calls your load function with the query, the page and an AbortSignal, and returns the props to spread onto the component: options, loading, error, paging and filter set to false.
How do I give the autocomplete an accessible name?
Pass aria-label or aria-labelledby to Autocomplete. The naming and validation attributes (id, title, aria-label, aria-labelledby, aria-describedby, aria-details, aria-errormessage, aria-invalid and aria-required) go to the element with role combobox, not to the wrapper.
Can the autocomplete submit with a plain HTML form?
Yes. Give it a name and it renders one hidden input per selected value. onBlur fires once when focus leaves the whole control, which is when form libraries mark a field as touched.
Can it show thousands of options?
Yes, with VirtualAutocomplete from slotsmith/virtual, which renders only the options in view. It needs @tanstack/react-virtual ^3.10.0 installed and takes the same props as Autocomplete, plus virtual.