Overview
The multi-select field family includes two components for different use cases:
MultiSelectField — For async data loading with support for pagination (page-based, offset-based, or group-based lazy loading)
- Includes automatic debouncing of the search input (configurable via the
debounceMs prop)
- Includes automatic caching of the search input (configurable via the
cache prop)
MultiSelectFieldSync — For client-side filtering of static option arrays
Both components provide a searchable dropdown interface for selecting multiple options, displaying selected values as chips with adaptive display modes (popover or dialog).
MultiSelectFieldSync (Static Options)
Use MultiSelectFieldSync when you have a static list of options that can be filtered client-side.import { useState } from "react";
import { MultiSelectFieldSync } from "@servicetitan/anvil2/beta";
const options = [
{ id: 1, label: "Option One" },
{ id: 2, label: "Option Two" },
{ id: 3, label: "Option Three" },
];
const ExampleComponent = () => {
const [selectedOptions, setSelectedOptions] = useState([]);
return (
<MultiSelectFieldSync
label="Select options"
placeholder="Search options..."
options={options}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
/>
);
};
Custom Filtering
By default, MultiSelectFieldSync uses match-sorter to filter options by their label and searchText fields. You can customize filtering in two ways:Using match-sorter options
<MultiSelectFieldSync
options={options}
filter={{ keys: ["label", "searchText"] }}
// ...other props
/>
Using a custom filter function
<MultiSelectFieldSync
options={options}
filter={(options, searchValue) => {
return options.filter((option) =>
option.label?.toLowerCase().includes(searchValue.toLowerCase())
);
}}
// ...other props
/>
MultiSelectField (Async Loading)
Use MultiSelectField when options need to be fetched from an API or when dealing with large datasets that require server-side filtering.Basic Usage
import { useState } from "react";
import { MultiSelectField } from "@servicetitan/anvil2/beta";
const ExampleComponent = () => {
const [selectedOptions, setSelectedOptions] = useState([]);
return (
<MultiSelectField
label="Select Tags"
placeholder="Search tags..."
loadOptions={async (searchValue) => {
const response = await fetch(`/api/tags?q=${searchValue}`);
const tags = await response.json();
return tags.map((tag) => ({
id: tag.id,
label: tag.name,
}));
}}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
/>
);
};
Lazy Loading Modes
MultiSelectField supports three lazy loading modes for paginated data:<MultiSelectField
lazy="page"
lazyOptions={{ pageSize: 10 }}
loadOptions={async (searchValue, pageNumber, pageSize) => {
const response = await fetch(
`/api/items?q=${searchValue}&page=${pageNumber}&size=${pageSize}`
);
const { data, totalCount } = await response.json();
return {
options: data.map((item) => ({ id: item.id, label: item.name })),
hasMore: pageNumber * pageSize + data.length < totalCount,
};
}}
// ...other props
/>
<MultiSelectField
lazy="offset"
lazyOptions={{ limit: 15 }}
loadOptions={async (searchValue, offset, limit) => {
const response = await fetch(
`/api/items?q=${searchValue}&offset=${offset}&limit=${limit}`
);
const { data, totalCount } = await response.json();
return {
options: data.map((item) => ({ id: item.id, label: item.name })),
hasMore: offset + limit < totalCount,
};
}}
// ...other props
/>
Group-based Loading
For loading grouped options incrementally:<MultiSelectField
lazy="group"
groupToString={(groupValue) => String(groupValue)}
loadOptions={async (searchValue, previousGroupKey) => {
const response = await fetch(
`/api/items?q=${searchValue}&afterGroup=${previousGroupKey || ""}`
);
const { data, hasMore } = await response.json();
return {
options: data.map((item) => ({
id: item.id,
label: item.name,
group: item.category, // Required for grouped options
})),
hasMore,
};
}}
// ...other props
/>
Select All
Enable bulk selection with the selectAll prop.MultiSelectField
With MultiSelectField, the parent component is responsible for handling the select/deselect logic via onClick and managing the isChecked state:import { useState } from "react";
import { MultiSelectField } from "@servicetitan/anvil2/beta";
const allOptions = [
{ id: 1, label: "Option One" },
{ id: 2, label: "Option Two" },
{ id: 3, label: "Option Three" },
];
const ExampleComponent = () => {
const [selectedOptions, setSelectedOptions] = useState([]);
const handleSelectAll = () => {
if (selectedOptions.length === allOptions.length) {
setSelectedOptions([]);
} else {
setSelectedOptions(allOptions);
}
};
return (
<MultiSelectField
label="Select Items"
loadOptions={async () => allOptions}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
selectAll={{
label: "Select All Items",
onClick: handleSelectAll,
isChecked:
selectedOptions.length === allOptions.length
? "checked"
: selectedOptions.length > 0
? "indeterminate"
: "unchecked",
}}
/>
);
};
MultiSelectFieldSync
MultiSelectFieldSync provides a simplified selectAll prop. Click handling and check state are managed automatically:// Enable with default label
<MultiSelectFieldSync
label="Select Tags"
options={options}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
selectAll
/>
// Enable with custom label
<MultiSelectFieldSync
label="Select Tags"
options={options}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
selectAll={{ label: "Select All Tags" }}
/>
// Enable with dynamic label based on check state
<MultiSelectFieldSync
label="Select Tags"
options={options}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
selectAll={{ label: (checked) => checked ? "Deselect All" : "Select All" }}
/>
Chip Display Options
Control how selected options are displayed as chips:Single Row Mode
Restrict the field to a single row height. Overflow chips are collapsed into a “+N” indicator:<MultiSelectField
label="Tags"
loadOptions={loadOptions}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
singleRow
/>
Max Chips
Limit the number of visible chips regardless of row height:<MultiSelectField
label="Tags"
loadOptions={loadOptions}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
maxChips={5}
/>
Display Modes
Control how the options menu is displayed using the displayMenuAs prop:// Automatically choose based on device (default)
<MultiSelectField displayMenuAs="auto" {...props} />
// Always show as popover (not recommended for mobile)
<MultiSelectField displayMenuAs="popover" {...props} />
// Always show as dialog
<MultiSelectField displayMenuAs="dialog" {...props} />
Caching
MultiSelectField caches loadOptions results by default. Configure caching behavior:// Disable caching
<MultiSelectField cache={{ enabled: false }} {...props} />
// Configure max cache size (default: 50)
<MultiSelectField cache={{ maxSize: 100 }} {...props} />
Clearing the Cache
Use a ref to imperatively clear the cache:import { useRef } from "react";
import { MultiSelectField } from "@servicetitan/anvil2/beta";
const ExampleComponent = () => {
const multiSelectRef = useRef(null);
const handleRefresh = () => {
multiSelectRef.current?.clearCache();
};
return (
<>
<MultiSelectField ref={multiSelectRef} {...props} />
<button onClick={handleRefresh}>Refresh Options</button>
</>
);
};
Initial Load Behavior
Control when options are first loaded with the initialLoad prop:// Load immediately on mount (default)
<MultiSelectField initialLoad="immediate" {...props} />
// Load when user opens the dropdown
<MultiSelectField initialLoad="open" {...props} />
// Auto (currently equivalent to "immediate")
<MultiSelectField initialLoad="auto" {...props} />
Field States
Error State
Display validation errors using the error prop:<MultiSelectField
label="Categories"
loadOptions={loadOptions}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
error={selectedOptions.length === 0 ? "Please select at least one category" : false}
/>
Hint and Description
Provide additional context with hint and description:<MultiSelectField
label="Categories"
loadOptions={loadOptions}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
hint="Select up to 5 categories"
description="These will be used for filtering"
/>
Required Field
Mark a field as required with the required prop:<MultiSelectField
label="Categories"
loadOptions={loadOptions}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
required
/>
Disabled and ReadOnly
When disabled is set, users cannot interact with the field:<MultiSelectField
label="Categories"
loadOptions={loadOptions}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
disabled
/>
When readOnly is set, users can see the dropdown but cannot change selections:<MultiSelectField
label="Categories"
loadOptions={loadOptions}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
readOnly
/>
Prefix and Suffix
Add content before or after the input with prefix and suffix:<MultiSelectField
label="Tags"
loadOptions={loadOptions}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
prefix="#"
suffix="tags"
/>
Sizes
Control the size of the field with the size prop:<MultiSelectField size="small" {...props} />
<MultiSelectField size="medium" {...props} /> {/* default */}
<MultiSelectField size="large" {...props} />
Disabled Options
Individual options can be disabled by setting disabled: true on the option:const options = [
{ id: 1, label: "Available Option" },
{ id: 2, label: "Unavailable Option", disabled: true },
{ id: 3, label: "Another Available Option" },
];
<MultiSelectField
label="Select options"
loadOptions={async () => options}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
/>
Pinned Options
Pin frequently used or suggested options above the list using the pinned prop. Each pinned section requires a label and an options value, which can be a static array or a dynamic loader function.Static Pinned Options
Pass an object with label and a static options array:<MultiSelectField
label="Books"
pinned={{
label: "Favorites",
options: [
{ id: "fav-1", label: "The Martian" },
{ id: "fav-2", label: "Dune" },
],
}}
loadOptions={fetchBooks}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
/>
Dynamic Pinned Options
Pass a function as options to compute pinned options based on the current search value. This is useful for AI-powered suggestions or context-aware recommendations:<MultiSelectField
label="Books"
pinned={{
label: "AI Suggestions",
options: async (searchValue) => {
const suggestions = await fetchAISuggestions(searchValue);
return suggestions.map((s) => ({ id: s.id, label: s.title }));
},
}}
loadOptions={fetchBooks}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
/>
By default, the loader re-runs whenever the search value changes. Set searchReactive: false to call the loader once and reuse the result across all search values:<MultiSelectField
label="Books"
pinned={{
label: "Your Favorites",
options: async () => {
return await fetchFavorites();
},
searchReactive: false,
}}
loadOptions={fetchBooks}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
/>
Multiple Pinned Sections
Pass an array of pinned section objects:<MultiSelectField
label="Books"
pinned={[
{
label: "AI Suggestions",
options: async (searchValue) => {
const suggestions = await fetchAISuggestions(searchValue);
return suggestions.map((s) => ({ id: s.id, label: s.title }));
},
},
{
label: "Your Favorites",
options: [
{ id: "fav-1", label: "Dune" },
{ id: "fav-2", label: "Foundation" },
],
},
]}
loadOptions={fetchBooks}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
/>
Grouping Options
Options can be organized into visual groups by adding a group property to each option. Groups appear as labeled sections in the dropdown.Basic Grouping
Add a group property to options and provide a groupToString function to display group labels:const options = [
{ id: 1, label: "Apple", group: "fruits" },
{ id: 2, label: "Banana", group: "fruits" },
{ id: 3, label: "Carrot", group: "vegetables" },
{ id: 4, label: "Broccoli", group: "vegetables" },
];
<MultiSelectField
label="Food"
loadOptions={async () => options}
groupToString={(group) =>
group === "fruits" ? "Fruits" : "Vegetables"
}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
/>
Group Sorting
Use groupSorter to control the order of groups. By default, groups appear in the order they are first encountered in the options array. This prop is available on MultiSelectFieldSync and non-lazy MultiSelectField:Avoid using groupSorter with lazy="group". When groups load incrementally from the server and get re-sorted, the menu content shifts unexpectedly, creating a disorienting user experience. Instead, have your server return groups in the desired order.
const options = [
{ id: 1, label: "Item A", group: 3 },
{ id: 2, label: "Item B", group: 1 },
{ id: 3, label: "Item C", group: 2 },
];
<MultiSelectField
label="Items"
loadOptions={async () => options}
groupToString={(group) => `Priority ${group}`}
groupSorter={(a, b) => Number(a) - Number(b)}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
/>
MultiSelectField Props
import { useState } from "react";
import { MultiSelectField } from "@servicetitan/anvil2/beta";
const ExampleComponent = () => {
const [selectedOptions, setSelectedOptions] = useState([]);
return (
<MultiSelectField
label="Search users"
loadOptions={async (searchValue) => {
const response = await fetch(`/api/users?q=${searchValue}`);
const users = await response.json();
return users.map((user) => ({
id: user.id,
label: user.name,
}));
}}
onSelectedOptionsChange={setSelectedOptions}
value={selectedOptions}
/>
);
};
The label of the multi-select field.
Function to load options. The signature depends on the lazy mode:
- Non-lazy:
(searchValue: string) => MultiSelectFieldOption[] | Promise<MultiSelectFieldOption[]>
- Page-based:
(searchValue: string, pageNumber: number, pageSize: number) => { options: MultiSelectFieldOption[], hasMore?: boolean }
- Offset-based:
(searchValue: string, offset: number, limit: number) => { options: MultiSelectFieldOption[], hasMore?: boolean }
- Group-based:
(searchValue: string, previousGroupKey: string | number | null) => { options: MultiSelectFieldGroupedOption[], hasMore?: boolean }
onSelectedOptionsChange
(options: MultiSelectFieldOption[]) => void
required
Callback fired when the selected options change. Receives the new array of selected options.
value
MultiSelectFieldOption[]
required
The currently selected options. Must be controlled state.
cache
MultiSelectFieldCacheOptions
Configuration for caching loadOptions results:
enabled — Whether caching is enabled (default: true)
maxSize — Maximum number of search values to cache before clearing (default: 15)
Custom CSS class name for the wrapper element.
Milliseconds to debounce search input before calling loadOptions.
Description text displayed below the input field.
Whether the field is disabled. When disabled, the field cannot be interacted with and the dropdown cannot open.
How to display the options menu:
auto — Popover on desktop, dialog on mobile
popover — Always display as popover
dialog — Always display as dialog
error
ReactElement | string | boolean
Error state for the field. When true, shows error styling. When a string or ReactElement, also displays as an error message below the field.
errorAriaLive
'off' | 'assertive' | 'polite'
default:"assertive"
Controls the aria-live behavior for error messages. See MDN docs. groupSorter
(a: MultiSelectFieldGroupByValue, b: MultiSelectFieldGroupByValue) => number
Function to compare two group values for sorting. When provided, groups are sorted using this comparator. Without this, groups appear in the order they are first encountered. Best used with MultiSelectFieldSync or non-lazy MultiSelectField. Avoid using with lazy="group" as it causes the menu to shift unexpectedly when new groups load.
groupToString
(groupValue: MultiSelectFieldGroupByValue) => string
Function to convert group values to display labels. Only used with grouped options. MultiSelectFieldGroupByValue is string | number.
Visually hides the label above the input while keeping it accessible to screen readers. Note: This does not affect the label displayed in the adaptive dialog view on mobile devices.
Hint text displayed below the input field.
The id of the multi-select field.
initialLoad
'auto' | 'immediate' | 'open'
default:"auto"
Controls when loadOptions is first called:
auto — Currently equivalent to immediate
immediate — Load on component mount
open — Load when dropdown is opened
Custom ReactNode to render as the label above the input, overriding the default label text. The label prop is still required for accessibility purposes. Note: This does not affect the label displayed in the adaptive dialog view on mobile devices.
lazy
'page' | 'offset' | 'group' | false
default:"false"
Enables lazy loading with the specified pagination strategy.
Configuration for lazy loading:
- Page mode:
{ pageSize?: number } (default pageSize: 20)
- Offset mode:
{ limit?: number } (default limit: 20)
- Group mode:
{}
Maximum number of chips to display before showing a “+N” indicator. Applies regardless of singleRow setting.
pinned
MultiSelectFieldPinnedOptions
Options to pin to the top of the list. Accepts a single section object or an array of section objects. Each section requires:
label — Display label for the section header
options — Static array (MultiSelectFieldOption[]) or dynamic loader function ((searchValue: string) => MultiSelectFieldOption[] | Promise<MultiSelectFieldOption[]>)
searchReactive — (Optional) Whether to re-call the loader when the search value changes. Defaults to true. Set to false to call the loader once and reuse the result.
cacheSize — (Optional) Maximum number of search results to cache per section. Defaults to 15. Only applies when searchReactive is true.
Placeholder text for the input field.
Content to display before the input field.
Whether the field is read-only. When read-only, the dropdown can open to view options but selections cannot be changed.
Whether the field is required. Shows a red asterisk (*) next to the label.
Configuration for the “Select All” option:
label — The label to display (default: “Select All”)
onClick — Callback when clicked. Parent component handles select/deselect logic.
isChecked — State: true/"checked", false/"unchecked", "indeterminate", or "loading"
When true, restricts the field to a single row height. Overflow chips collapse into a “+N” indicator.
size
'small' | 'medium' | 'large'
The size of the multi-select field.
Custom inline styles for the wrapper element.
Content to display after the input field.
MultiSelectFieldSync Props
MultiSelectFieldSync accepts all props from MultiSelectField except loadOptions, lazy, debounceMs, cache, and initialLoad, plus the following:import { useState } from "react";
import { MultiSelectFieldSync } from "@servicetitan/anvil2/beta";
const options = [
{ id: 1, label: "Option One" },
{ id: 2, label: "Option Two" },
{ id: 3, label: "Option Three" },
];
const ExampleComponent = () => {
const [selectedOptions, setSelectedOptions] = useState([]);
return (
<MultiSelectFieldSync
label="Select options"
onSelectedOptionsChange={setSelectedOptions}
options={options}
value={selectedOptions}
/>
);
};
The label of the multi-select field.
onSelectedOptionsChange
(options: MultiSelectFieldOption[]) => void
required
Callback fired when the selected options change. Receives the new array of selected options.
options
MultiSelectFieldOption[]
required
The array of options to display in the multi-select field.
value
MultiSelectFieldOption[]
required
The currently selected options. Must be controlled state.
Custom CSS class name for the wrapper element.
Description text displayed below the input field.
Whether the field is disabled. When disabled, the field cannot be interacted with and the dropdown cannot open.
How to display the options menu.
error
ReactElement | string | boolean
Error state for the field. When true, shows error styling. When a string or ReactElement, also displays as an error message below the field.
errorAriaLive
'off' | 'assertive' | 'polite'
default:"assertive"
Controls the aria-live behavior for error messages.
filter
function | MatchSorterOptions
Custom filter configuration. Can be:
- A function:
(options: MultiSelectFieldOption[], searchValue: string) => MultiSelectFieldOption[]
- A match-sorter options object to customize the default filtering
Default: Filters by label and searchText fields using match-sorter. groupSorter
(a: MultiSelectFieldGroupByValue, b: MultiSelectFieldGroupByValue) => number
Function to compare two group values for sorting. When provided, options are sorted by group using this comparator, then by match-sort order within each group. Ungrouped options appear last.
groupToString
(groupValue: MultiSelectFieldGroupByValue) => string
Function to convert group values to display labels. Only used with grouped options. MultiSelectFieldGroupByValue is string | number.
Visually hides the label above the input while keeping it accessible to screen readers. Note: This does not affect the label displayed in the adaptive dialog view on mobile devices.
Hint text displayed below the input field.
The id of the multi-select field.
Custom ReactNode to render as the label above the input, overriding the default label text. The label prop is still required for accessibility purposes. Note: This does not affect the label displayed in the adaptive dialog view on mobile devices.
Maximum number of chips to display before showing a “+N” indicator.
pinned
MultiSelectFieldPinnedOptions
Options to pin to the top of the list. Accepts the same section object format as MultiSelectField’s pinned prop.
Placeholder text for the input field.
Content to display before the input field.
Whether the field is read-only. When read-only, the dropdown can open to view options but selections cannot be changed.
Whether the field is required. Shows a red asterisk (*) next to the label.
selectAll
boolean | { label?: string | ((checked: boolean) => string) }
Enables the “Select All” option at the top of the list. Can be:
true — Enable with default label “Select All”
{ label: "Custom Label" } — Enable with custom static label
{ label: (checked) => checked ? "Deselect All" : "Select All" } — Enable with dynamic label based on check state
Click handling and check state are managed automatically based on comparing options with value. When true, restricts the field to a single row height. Overflow chips collapse into a “+N” indicator.
size
'small' | 'medium' | 'large'
The size of the multi-select field.
Custom inline styles for the wrapper element.
Content to display after the input field.