Skip to content

Autocomplete

Autocomplete provides fast filtering of large datasets through live search results based on user input.
Figma logo
A points to TextField; B points Placeholder text; C points to a Spinner; D points to a clear button
Illustrating the pieces that make up an Autocomplete Field
Explaining the pieces that make up an Autocomplete Field
ItemNameDescription
A<TextField /><AutocompleteField /> uses a <TextField /> under the hood.
BValueThe placeholder or typed text string.
C<Spinner />Rendered when loading is true to give a visual cue that content is loading.
D<IconButton />The <Close /> icon is used within <IconButton /> to clear the entered or selected text string.
A points to the Autocomplete Listbox container; B points to an Autocomplete Option; C points to a group label; D points to an Autocomplete Option Group
Illustrating the pieces that make up an Autocomplete Listbox
Explaining the pieces that make up an Autocomplete Listbox
ItemNameDescription
AContainerThe parent component–rendered as a <ul>—that wraps <AutocompleteOption /> children components.
B<AutocompleteOption />The child component—rendered as an <li>—that contains the main content for a given step.
CGroup labelA <span> generated by the label prop when grouping options.
D<AutocompleteOptionGroup />A group of <AutocompleteOptions /> using a required label.
A points to the Autocomplete Listbox container; B points to an Autocomplete Option; C points to a group label; D points to an Autocomplete Option Group
Illustrating the pieces that make up an Autocomplete Option
Explaining the pieces that make up an Autocomplete Listbox
ItemNameDescription
A<Check />The <Check /> icon is used to indicate that the <AutocompleteOption /> is selected. The icon is omitted when using the search behavior.
BOption contentContains any other children passed to the <AutocompleteOption /> such as a text string or image.

For a smooth selection process from a fixed list, use variant="select" for filtering. This example shows how filtering helps you quickly find and choose valid options, ensuring you can only select from what is available in the listbox. This behavior is similar to a<select /> field.

import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Formik } from 'formik'
import * as Yup from 'yup'
import {
AutocompleteField,
AutocompleteListbox,
AutocompleteOption,
FormLayout,
} from '@gusto/workbench-formik'
import type { Country, Employee } from './_testData'
import { countries, employees } from './_testData'
const employeesQueryCache = new Map<string, Employee[]>()
const queryEmployees = async (search: string): Promise<Employee[]> => {
let output = employeesQueryCache.get(search)
if (!output) {
const getEmployees = async () =>
new Promise<Employee[]>(resolve => {
const normalizedSearch = search.toLocaleLowerCase()
const artificialDelay = Math.floor(Math.random() * 1000)
setTimeout(() => {
const data = employees.filter(e => {
return (
e['Name-FL'].toLocaleLowerCase().includes(normalizedSearch) ||
e.Title.toLocaleLowerCase().includes(normalizedSearch)
)
})
resolve(data)
}, artificialDelay)
})
output = await getEmployees()
employeesQueryCache.set(search, output)
}
return output
}
export const Filtering = () => {
const getOptions = useCallback((searchValue: string) => {
return countries
.filter(country => {
return country.name
.toLocaleLowerCase()
.startsWith(searchValue.toLocaleLowerCase())
})
.slice(0, 5)
}, [])
const [options, setOptions] = useState(() => getOptions(''))
const selectedCountry = useRef<Country | null>(null)
return (
<Formik
initialValues={{
country: 'af',
}}
validationSchema={Yup.object().shape({
country: Yup.string().nullable(),
})}
onSubmit={values => alert(JSON.stringify(values))}
>
<FormLayout
fields={
<AutocompleteField
name="country"
label="Country"
onDebouncedChange={e => setOptions(getOptions(e.target.value))}
variant="select"
defaultSelectedOptions={[
{
label: 'Afghanistan',
value: 'af',
},
]}
>
<AutocompleteListbox>
{options.map(opt => (
<AutocompleteOption
key={opt.code}
option={{ label: opt.name, value: opt.code }}
onClick={() => {
selectedCountry.current = opt
}}
/>
))}
</AutocompleteListbox>
</AutocompleteField>
}
/>
</Formik>
)
}

When you need flexibility to enter any valid text, use variant="text" for searching. This example shows how searching allows you to type and submit any text, providing a versatile input experience beyond predefined options.

Select an employee

import React, { useEffect, useMemo, useState } from 'react'
import { Formik } from 'formik'
import * as Yup from 'yup'
import {
Flex,
FlexItem,
Heading,
HeadingSubtext,
HeadingText,
Img,
} from '@gusto/workbench'
import {
AutocompleteField,
AutocompleteListbox,
AutocompleteOption,
FormLayout,
} from '@gusto/workbench-formik'
import { Search } from '@gusto/workbench-icons'
import type { Employee } from './_testData'
import { employees } from './_testData'
const employeesQueryCache = new Map<string, Employee[]>()
const queryEmployees = async (search: string): Promise<Employee[]> => {
let output = employeesQueryCache.get(search)
if (!output) {
const getEmployees = async () =>
new Promise<Employee[]>(resolve => {
const normalizedSearch = search.toLocaleLowerCase()
const artificialDelay = Math.floor(Math.random() * 1000)
setTimeout(() => {
const data = employees.filter(e => {
return (
e['Name-FL'].toLocaleLowerCase().includes(normalizedSearch) ||
e.Title.toLocaleLowerCase().includes(normalizedSearch)
)
})
resolve(data)
}, artificialDelay)
})
output = await getEmployees()
employeesQueryCache.set(search, output)
}
return output
}
const useEmployeesQuery = (search: string) => {
const [loading, setLoading] = useState(false)
const [data, setData] = useState<Employee[] | undefined>()
useEffect(() => {
const fetchEmployees = async () => {
setLoading(true)
setData(await queryEmployees(search))
setLoading(false)
}
void fetchEmployees()
}, [search])
return {
data,
loading,
}
}
export const SearchSingle = () => {
const [searchTerm, setSearchTerm] = useState('')
const [queryEnabled, setQueryEnabled] = useState(false)
const { data, loading } = useEmployeesQuery(searchTerm)
const options = useMemo(
() =>
data?.map(({ Email: email, 'Name-FL': fullName, ...rest }) => ({
email,
fullName,
label: fullName,
value: email,
...rest,
})) ?? [],
[data],
)
return (
<Formik
initialValues={{
employee: '',
}}
validationSchema={Yup.object().shape({
employee: Yup.string().nullable().required('Country is required'),
})}
onSubmit={values => alert(JSON.stringify(values))}
>
<FormLayout
fields={
<AutocompleteField
label="Employee search"
helperText="Select an employee"
name="employeeName"
before={<Search />}
loading={queryEnabled ? loading : false}
placeholder="Search employees..."
onDebouncedChange={e => setSearchTerm(e.target.value)}
onFocus={() => setQueryEnabled(true)}
variant="text"
>
<AutocompleteListbox>
{options.map(
({ Avatar: avatar, Title: jobTitle, email, fullName }) => (
<AutocompleteOption
key={email}
option={{ label: fullName, value: email }}
>
<Flex columnGap={3} alignItems="center">
<FlexItem
width={48}
borderRadius={1000}
overflow="hidden"
>
<Img
src={avatar}
alt={`Avatar for ${fullName}`}
width={500}
height={500}
/>
</FlexItem>
<FlexItem>
{/* Done for styling only— this has no semantic meaning because it is inside of an option */}
<Heading role="presentation" level={5} marginBottom={0}>
<HeadingText>{fullName}</HeadingText>
<HeadingSubtext>{jobTitle}</HeadingSubtext>
</Heading>
</FlexItem>
</Flex>
</AutocompleteOption>
),
)}
</AutocompleteListbox>
</AutocompleteField>
}
/>
</Formik>
)
}

For easier navigation through options, use grouping with the use of <AutocompleteOptionGroup /> and <AutocompleteSeparator />. This example shows how grouping organizes the listbox options into categories, making it simpler to find and select the right choice.

import React, { useEffect, useMemo, useState } from 'react'
import { Formik } from 'formik'
import * as Yup from 'yup'
import {
AutocompleteField,
AutocompleteListbox,
AutocompleteOption,
AutocompleteOptionGroup,
AutocompleteSeparator,
FormLayout,
} from '@gusto/workbench-formik'
import type { Employee } from './_testData'
import { employees } from './_testData'
const employeesQueryCache = new Map<string, Employee[]>()
const queryEmployees = async (search: string): Promise<Employee[]> => {
let output = employeesQueryCache.get(search)
if (!output) {
const getEmployees = async () =>
new Promise<Employee[]>(resolve => {
const normalizedSearch = search.toLocaleLowerCase()
const artificialDelay = Math.floor(Math.random() * 1000)
setTimeout(() => {
const data = employees.filter(e => {
return (
e['Name-FL'].toLocaleLowerCase().includes(normalizedSearch) ||
e.Title.toLocaleLowerCase().includes(normalizedSearch)
)
})
resolve(data)
}, artificialDelay)
})
output = await getEmployees()
employeesQueryCache.set(search, output)
}
return output
}
const useEmployeesQuery = (search: string) => {
const [loading, setLoading] = useState(false)
const [data, setData] = useState<Employee[] | undefined>()
useEffect(() => {
const fetchEmployees = async () => {
setLoading(true)
setData(await queryEmployees(search))
setLoading(false)
}
void fetchEmployees()
}, [search])
return {
data,
loading,
}
}
export const Grouped = () => {
const [searchTerm, setSearchTerm] = useState('')
const { data, loading } = useEmployeesQuery(searchTerm)
const options = useMemo(
() =>
data?.map(
({
Department: department,
Email: email,
'Name-FL': fullName,
...rest
}) => ({
department,
email,
fullName,
label: fullName,
value: email,
...rest,
}),
) ?? [],
[data],
)
const groupedOptions = useMemo(() => {
const sortedOptions = [...options].sort((a, b) => {
return a.department.localeCompare(b.department, undefined, {
sensitivity: 'base',
})
})
const groups: Record<string, typeof options> = {}
sortedOptions.forEach(option => {
const groupKey = option.department
const groupValue = groups[groupKey] ?? []
groupValue.push(option)
groups[groupKey] = groupValue
})
return groups
}, [options])
return (
<Formik
initialValues={{
employee: '',
}}
validationSchema={Yup.object().shape({
employee: Yup.string().nullable().required('Country is required'),
})}
onSubmit={values => alert(JSON.stringify(values))}
>
<FormLayout
onSubmit={e => {
const employeeName = new FormData(e.target as HTMLFormElement).get(
'employeeName',
)
let formData = 'Empty'
if (typeof employeeName === 'string' && employeeName.length > 0) {
formData = `Selected: ${employeeName}`
}
console.log(formData)
e.preventDefault()
}}
fields={
<AutocompleteField
name="employee"
label="Employee"
onDebouncedChange={e => setSearchTerm(e.target.value)}
placeholder="Enter a country name"
loading={loading}
>
<AutocompleteListbox>
{Object.entries(groupedOptions).map(
([groupLabel, groupOptions], index) => {
return (
<React.Fragment key={groupLabel}>
{index > 0 ? <AutocompleteSeparator /> : null}
<AutocompleteOptionGroup label={groupLabel}>
{groupOptions.map(option => (
<AutocompleteOption
key={option.value}
option={{
label: option.label,
value: option.value,
}}
>
{option.label}
</AutocompleteOption>
))}
</AutocompleteOptionGroup>
</React.Fragment>
)
},
)}
</AutocompleteListbox>
</AutocompleteField>
}
/>
</Formik>
)
}

When you need to choose more than one option, use multiple selection. This example demonstrates how you can select multiple items from the autocomplete, giving you the flexibility to choose all the options you need.

Select any number of employees

import React, { useEffect, useRef, useState } from 'react'
import { Formik } from 'formik'
import * as Yup from 'yup'
import { type AutocompleteOptionBase } from '@gusto/workbench'
import {
AutocompleteField,
AutocompleteListbox,
AutocompleteOption,
FormLayout,
} from '@gusto/workbench-formik'
import { Search } from '@gusto/workbench-icons'
import type { Employee } from './_testData'
import { employees } from './_testData'
const employeesQueryCache = new Map<string, Employee[]>()
const queryEmployees = async (search: string): Promise<Employee[]> => {
let output = employeesQueryCache.get(search)
if (!output) {
const getEmployees = async () =>
new Promise<Employee[]>(resolve => {
const normalizedSearch = search.toLocaleLowerCase()
const artificialDelay = Math.floor(Math.random() * 1000)
setTimeout(() => {
const data = employees.filter(e => {
return (
e['Name-FL'].toLocaleLowerCase().includes(normalizedSearch) ||
e.Title.toLocaleLowerCase().includes(normalizedSearch)
)
})
resolve(data)
}, artificialDelay)
})
output = await getEmployees()
employeesQueryCache.set(search, output)
}
return output
}
const useEmployeesQuery = (search: string) => {
const [loading, setLoading] = useState(false)
const [data, setData] = useState<Employee[] | undefined>()
useEffect(() => {
const fetchEmployees = async () => {
setLoading(true)
setData(await queryEmployees(search))
setLoading(false)
}
void fetchEmployees()
}, [search])
return {
data,
loading,
}
}
export const SearchMultiple = () => {
const inputRef = useRef<HTMLInputElement>(null)
const [searchTerm, setSearchTerm] = useState('')
const [queryEnabled, setQueryEnabled] = useState(false)
const { data, loading } = useEmployeesQuery(searchTerm)
const [selectedOptions, setSelectedOptions] = useState<
AutocompleteOptionBase[]
>([
{
label: 'Emily Lee',
value: 'Emily Lee',
},
])
return (
<Formik
initialValues={{
employee: [],
}}
validationSchema={Yup.object().shape({
employee: Yup.string().nullable().required('Employee is required'),
})}
onSubmit={values => alert(JSON.stringify(values))}
>
<FormLayout
fields={
<AutocompleteField
ref={inputRef}
before={<Search />}
label="Employee"
name="employee"
selectedOptions={selectedOptions}
onDebouncedChange={e => setSearchTerm(e.target.value)}
onSelectedOptionsChange={(options: AutocompleteOptionBase[]) => {
setSelectedOptions(options)
}}
variant="select"
onFocus={() => setQueryEnabled(true)}
placeholder="Type to filter options..."
loading={queryEnabled ? loading : false}
helperText="Select any number of employees"
multiple
>
<input
type="hidden"
value={selectedOptions.map(v => v.value?.toString() ?? '')}
name="employees"
/>
<AutocompleteListbox>
{data?.map(({ Digit, 'Name-FL': name }) => (
<AutocompleteOption
key={Digit}
option={{ label: name, value: name }}
>
{name}
</AutocompleteOption>
))}
</AutocompleteListbox>
</AutocompleteField>
}
/>
</Formik>
)
}
React props
NameTypeDefaultDescription
after  
ReactNode
-Content shown after the input value. By default this content is not interactive and will ignore pointer events. Any interactive elements must be styled with pointer-events: auto;.
before  
ReactNode
-Content shown before the input value. By default this content is not interactive and will ignore pointer events. Any interactive elements must be styled with pointer-events: auto;.
children  
ReactNode
-Content of the field; typically an AutocompleteSelect and AutocompleteListbox
component  
ReactNode
-Component override for the input element
counter  
ReactNode
-Indicator of a limit placed on a field, e.g. maximum character count or remaining hours; most commonly using CharacterCountExternal link
debounceDelay  
number
200In milliseconds, the amount of time between value changes before the onDebouncedChange event is triggered
defaultSelectedOptions  
AutocompleteOptionBase[]
-The default options selected on initial render.
preventSelectionOnBlur  
boolean
When true, prevents selection of the active option when the input loses focus (on Tab or click away).
fit  
contentcontainer
containerDetermines the point of reference for the width of the component. If set to content, the size will be set by the size of the input. If set to container, the size will expand to fit the containing element.
helperText  
ReactNode
-A brief description or hint for the input; can be used to link to an external resource.
invalid  
boolean
-If true, the field will be shown in an invalid state.
label  Required
string
-Label associated with the input.
loading  
boolean
falseIf true, the loading Spinner is displayed.
onClear  
() => void
-Called when the ESCAPE key is pressed or the clear button is clicked.
onDebouncedChange  
(e: React.ChangeEvent<HTMLInputElement>) => void
-Called after the debounceDelay has passed since the last input value change.
onImmediateChange  
(e: React.ChangeEvent<HTMLInputElement>) => void
-Called as soon as the input value changes.
onSelectedOptionsChange  
(options: AutocompleteOptionBase[]) => void
-Called when the selected options change.
optional  
boolean
-Used to indicate the field is optional
validationText  
ReactNode
-Validation message associated with the input.
variant  
selecttext
textDetermines the behavior of input entered in the field.text behaves like an input text element and allows any input-valued to be used, whether or not it matches an option.select behaves more like a select element and only allows available option elements to be used.
React props
NameTypeDefaultDescription
children  
ReactNode
-Content of the component, typically a series of AutocompleteOption
React props
NameTypeDefaultDescription
children  
ReactNode
-Content of the component
label  Deprecated
string
-Use option instead The text entered into the input element when the option is selected
value  Deprecated
stringnumber
-Use option instead The value for the option.
option  Required
AutocompleteOptionBase
-The option configuration object: { label: string, value: string }
React props
NameTypeDefaultDescription
label  Required
string
-The group label

The following testing snippet(s) offer suggestions for testing the component using React Testing LibraryExternal link with occasional help from JestExternal link.

const countries = [
{ code: 'af', name: 'Afghanistan' },
// ...continued
];
// Tested component where state is being managed
const TestedComponent = () => {
const [inputValue, setInputValue] = useState('');
const [selectedCountry, setSelectedCountry] = useState<Country | null>(null);
const options = useMemo(() => {
const searchValue = inputValue.toLowerCase();
return countries
.filter(country => country.name.startsWith('A'))
.filter(country => country.name.toLowerCase().startsWith(searchValue))
.slice(0, 6);
}, [inputValue]);
return (
<AutocompleteField
label="Country"
value={inputValue}
onChange={e => setInputValue(e.target.value)}
>
<AutocompleteSelect
name="country"
value={selectedCountry.code}
onChange={e => {
const newCountry = countries.find(country => country.code === e.target.value) ?? null;
setSelectedCountry(newCountry);
}}
>
{selectedCountry ? (
<Option
label={selectedCountry.name}
value={selectedCountry.code} />
) : null}
</AutocompleteSelect>
<AutocompleteListbox aria-label="Country options">
<AutocompleteOptionGroup label="A">
{options.map(option => (
<AutocompleteOption key={option.code} option={{ label: name, value: code }>
{option.name}
</AutocompleteOption>
))}
</AutocompleteOptionGroup>
</AutocompleteListbox>
</AutocompleteField>
};
render(<TestedComponent />);
const combobox = screen.getByRole('combobox', {
name: 'Country',
});
// The listbox, clear button, and the options elements will all be hidden
// on first render. To unhide them, search for something:
userEvent.type(combobox, 'a');
expect(combobox).toHaveDisplayValue('a');
expect(listbox).toBeVisible();
const clearButton = screen.getByRole('button', {
name: 'Clear',
});
const listbox = screen.getByRole('listbox', {
name: 'Country options',
});
const group = within(listbox).getByRole('group', {
name: 'A',
});
const afghanistanOption = within(group).getByRole('option', {
name: 'Afghanistan',
});
// To select an option:
userEvent.click(afghanistanOption);
// To clear the selected option:
userEvent.click(clearButton);
// Note: we recommend that you do not directly assert the selected value.
// Instead, assert displayed values and things you can see. If the selected
// value changes something else in the UI, assert on that instead