Overview
The multi-select menu family includes two components for different use cases:
MultiSelectMenu — 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)
MultiSelectMenuSync — For client-side filtering of static option arrays
Both components attach a searchable dropdown to any trigger element via a trigger render prop. The menu stays open after selecting an option, allowing multiple selections. Use MultiSelectMenu when you need multi-selection behavior outside of a form field context — for example, attaching a dropdown to a button, icon, or custom element.Looking for a form field with a built-in label, error state, and chip display? Use MultiSelectField instead. Use MultiSelectMenuSync when you have a static list of options that can be filtered client-side.import { useState } from "react";
import { MultiSelectMenuSync } from "@servicetitan/anvil2/beta";
import { Button } from "@servicetitan/anvil2";
const options = [
{ id: 1, label: "Option One" },
{ id: 2, label: "Option Two" },
{ id: 3, label: "Option Three" },
];
const ExampleComponent = () => {
const [selectedOptions, setSelectedOptions] = useState([]);
return (
<MultiSelectMenuSync
label="Options"
options={options}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
trigger={(props) => (
<Button {...props}>
{selectedOptions.length > 0
? `${selectedOptions.length} selected`
: "Select options"}
</Button>
)}
/>
);
};
Filtering and Sorting
By default, MultiSelectMenuSync uses match-sorter to filter options by their label and searchText fields. Results are also ranked by match quality, so the best matches appear first. Before any search is performed, options appear in the order they are supplied.
You can customize this behavior in two ways:Using match-sorter options
Pass a match-sorter options object to customize the default filtering and sorting behavior (e.g., change which keys are matched or adjust ranking):<MultiSelectMenuSync
options={options}
filter={{
keys: ["label", "searchText"],
// ...other match-sorter options
}}
// ...other props
/>
Using a custom filter function
Pass a function for full control over both filtering and sort order. The returned array determines the exact order options appear in the dropdown:<MultiSelectMenuSync
options={options}
filter={(options, searchValue) => {
return options.filter((option) =>
option.label?.toLowerCase().includes(searchValue.toLowerCase())
);
}}
// ...other props
/>
Use MultiSelectMenu 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 { MultiSelectMenu } from "@servicetitan/anvil2/beta";
import { Button } from "@servicetitan/anvil2";
const ExampleComponent = () => {
const [selectedOptions, setSelectedOptions] = useState([]);
return (
<MultiSelectMenu
label="Tags"
searchPlaceholder="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}
trigger={(props) => (
<Button {...props}>
{selectedOptions.length > 0
? `${selectedOptions.length} tags selected`
: "Select tags"}
</Button>
)}
/>
);
};
Lazy Loading Modes
MultiSelectMenu supports three lazy loading modes for paginated data:<MultiSelectMenu
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
/>
<MultiSelectMenu
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:<MultiSelectMenu
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,
})),
hasMore,
};
}}
// ...other props
/>
Select All
Enable bulk selection with the selectAll prop.Select All is shown only when the search input is empty.
With MultiSelectMenu, the parent component is responsible for handling the select/deselect logic via onClick and managing the checkState:import { useState } from "react";
import { MultiSelectMenu } from "@servicetitan/anvil2/beta";
import { Button } from "@servicetitan/anvil2";
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 (
<MultiSelectMenu
label="Items"
loadOptions={async () => allOptions}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
selectAll={{
label: "Select All Items",
onClick: handleSelectAll,
checkState:
selectedOptions.length === allOptions.length
? "checked"
: selectedOptions.length > 0
? "indeterminate"
: "unchecked",
}}
trigger={(props) => (
<Button {...props}>
{selectedOptions.length > 0
? `${selectedOptions.length} selected`
: "Select items"}
</Button>
)}
/>
);
};
MultiSelectMenuSync provides a simplified selectAll prop. Click handling and check state are managed automatically:// Enable with default label
<MultiSelectMenuSync
label="Tags"
options={options}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
selectAll
trigger={(props) => <Button {...props}>Select tags</Button>}
/>
// Enable with custom label
<MultiSelectMenuSync
label="Tags"
options={options}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
selectAll={{ label: "Select All Tags" }}
trigger={(props) => <Button {...props}>Select tags</Button>}
/>
// Enable with dynamic label based on check state
<MultiSelectMenuSync
label="Tags"
options={options}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
selectAll={{ label: (checked) => checked ? "Deselect All" : "Select All" }}
trigger={(props) => <Button {...props}>Select tags</Button>}
/>
Select Filtered
Enable selection of options matching the current search term with the selectFiltered prop. Select All and Select Filtered are mutually exclusive: Select All is shown when the search input is empty, and Select Filtered is shown when a search term is active.With MultiSelectMenu, provide a function that receives the current searchValue and returns a config object with onClick, checkState, and an optional label:import { useState } from "react";
import { MultiSelectMenu } from "@servicetitan/anvil2/beta";
import { Button } from "@servicetitan/anvil2";
const allOptions = [
{ id: 1, label: "Apple" },
{ id: 2, label: "Apricot" },
{ id: 3, label: "Banana" },
{ id: 4, label: "Cherry" },
];
const filterBySearch = (searchValue: string) =>
allOptions.filter((opt) =>
opt.label.toLowerCase().includes(searchValue.toLowerCase()),
);
const ExampleComponent = () => {
const [selectedOptions, setSelectedOptions] = useState([]);
const selectedIds = new Set(selectedOptions.map((o) => o.id));
return (
<MultiSelectMenu
label="Fruits"
loadOptions={async (searchValue) => filterBySearch(searchValue)}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
selectFiltered={(searchValue) => {
const filtered = filterBySearch(searchValue);
const allFilteredSelected =
filtered.length > 0 &&
filtered.every((o) => selectedIds.has(o.id));
const someFilteredSelected = filtered.some((o) =>
selectedIds.has(o.id),
);
return {
onClick: () => {
if (allFilteredSelected) {
const filteredIds = new Set(filtered.map((o) => o.id));
setSelectedOptions(
selectedOptions.filter((o) => !filteredIds.has(o.id)),
);
} else {
const merged = [...selectedOptions];
for (const opt of filtered) {
if (!selectedIds.has(opt.id)) merged.push(opt);
}
setSelectedOptions(merged);
}
},
checkState: allFilteredSelected
? "checked"
: someFilteredSelected
? "indeterminate"
: "unchecked",
};
}}
trigger={(props) => (
<Button {...props}>
{selectedOptions.length > 0
? `${selectedOptions.length} selected`
: "Select fruits"}
</Button>
)}
/>
);
};
MultiSelectMenuSync provides a simplified selectFiltered prop. Click handling and check state are managed automatically based on the filtered options and current selection:// Enable with default dynamic label
<MultiSelectMenuSync
label="Fruits"
options={options}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
selectFiltered
trigger={(props) => <Button {...props}>Select fruits</Button>}
/>
// Enable with custom label based on search value
<MultiSelectMenuSync
label="Fruits"
options={options}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
selectFiltered={(searchValue) => ({
label: `Select items matching "${searchValue}"`,
})}
trigger={(props) => <Button {...props}>Select fruits</Button>}
/>
// Combine with selectAll for full bulk selection
<MultiSelectMenuSync
label="Fruits"
options={options}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
selectAll
selectFiltered
trigger={(props) => <Button {...props}>Select fruits</Button>}
/>
Display Modes
Control how the options menu is displayed using the displayMenuAs prop:// Automatically choose based on device (default)
<MultiSelectMenu displayMenuAs="auto" {...props} />
// Always show as popover (not recommended for mobile)
<MultiSelectMenu displayMenuAs="popover" {...props} />
// Always show as dialog
<MultiSelectMenu displayMenuAs="dialog" {...props} />
Popover Width
Control the width of the popover using the width prop:// Fixed pixel width
<MultiSelectMenu width={300} {...props} />
// CSS string value
<MultiSelectMenu width="20rem" {...props} />
Caching
MultiSelectMenu caches loadOptions results by default. Configure caching behavior:// Disable caching
<MultiSelectMenu cache={{ enabled: false }} {...props} />
// Configure max cache size (default: 15)
<MultiSelectMenu cache={{ maxSize: 100 }} {...props} />
Clearing the Cache
Use a ref to imperatively clear the cache:import { useRef } from "react";
import { MultiSelectMenu } from "@servicetitan/anvil2/beta";
const ExampleComponent = () => {
const multiSelectMenuRef = useRef(null);
const handleRefresh = () => {
multiSelectMenuRef.current?.clearCache();
};
return (
<>
<MultiSelectMenu ref={multiSelectMenuRef} {...props} />
<button onClick={handleRefresh}>Refresh Options</button>
</>
);
};
Invalidating Options
Call invalidate() to clear the cache and reload options from the data source. Use this when the underlying data has changed and the component needs to reflect the update:import { useRef } from "react";
import { MultiSelectMenu } from "@servicetitan/anvil2/beta";
const ExampleComponent = () => {
const multiSelectMenuRef = useRef(null);
const handleDataSourceChange = () => {
multiSelectMenuRef.current?.invalidate();
};
return (
<MultiSelectMenu ref={multiSelectMenuRef} {...props} />
);
};
MultiSelectMenuSync handles this automatically when its options prop changes.
Initial Load Behavior
Control when options are first loaded with the initialLoad prop:// Load immediately on mount (default for "auto")
<MultiSelectMenu initialLoad="immediate" {...props} />
// Load when user opens the dropdown
<MultiSelectMenu initialLoad="open" {...props} />
// Auto (currently equivalent to "immediate")
<MultiSelectMenu initialLoad="auto" {...props} />
Disabling Search
Pass disableSearch to remove the search input from inside the menu. The menu renders only the option list, and keyboard focus moves directly to the list container.This is useful when the option list is short and well-known or you are integrating with an API that does not support search.import { useState } from "react";
import { MultiSelectMenuSync } from "@servicetitan/anvil2/beta";
import { Button } from "@servicetitan/anvil2";
const options = [
{ id: 1, label: "Red" },
{ id: 2, label: "Green" },
{ id: 3, label: "Blue" },
{ id: 4, label: "Yellow" },
];
const ExampleComponent = () => {
const [selectedOptions, setSelectedOptions] = useState([]);
return (
<MultiSelectMenuSync
disableSearch
selectAll
label="Colors"
options={options}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
trigger={(props) => (
<Button {...props}>
{selectedOptions.length > 0
? `${selectedOptions.length} colors selected`
: "Select colors"}
</Button>
)}
/>
);
};
When disableSearch is enabled, selectFiltered has no effect since there is no search input to produce filtered results.
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" },
];
<MultiSelectMenuSync
label="Select options"
options={options}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
trigger={(props) => <Button {...props}>Choose</Button>}
/>
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:<MultiSelectMenu
label="Books"
pinned={{
label: "Favorites",
options: [
{ id: "fav-1", label: "The Martian" },
{ id: "fav-2", label: "Dune" },
],
}}
loadOptions={fetchBooks}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
trigger={(props) => <Button {...props}>Select books</Button>}
/>
Dynamic Pinned Options
Pass a function as options to compute pinned options based on the current search value:<MultiSelectMenu
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}
trigger={(props) => <Button {...props}>Select books</Button>}
/>
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:<MultiSelectMenu
label="Books"
pinned={{
label: "Your Favorites",
options: async () => {
return await fetchFavorites();
},
searchReactive: false,
}}
loadOptions={fetchBooks}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
trigger={(props) => <Button {...props}>Select books</Button>}
/>
Multiple Pinned Sections
Pass an array of pinned section objects:<MultiSelectMenu
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}
trigger={(props) => <Button {...props}>Select books</Button>}
/>
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" },
];
<MultiSelectMenu
label="Food"
loadOptions={async () => options}
groupToString={(group) =>
group === "fruits" ? "Fruits" : "Vegetables"
}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
trigger={(props) => <Button {...props}>Select food</Button>}
/>
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 MultiSelectMenuSync and non-lazy MultiSelectMenu: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 },
];
<MultiSelectMenu
label="Items"
loadOptions={async () => options}
groupToString={(group) => `Priority ${group}`}
groupSorter={(a, b) => Number(a) - Number(b)}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
trigger={(props) => <Button {...props}>Select items</Button>}
/>
Virtualization
By default, all dropdown options render to the DOM at once. This works well for typical lists but degrades performance with very large option sets. Pass virtualize to enable windowed rendering, which only renders the items currently visible in the scroll viewport plus a small overscan buffer.Consider enabling virtualize when the dropdown feels sluggish to open or keyboard navigation becomes laggy. These symptoms typically appear around 200-500 items depending on device performance and item complexity.Virtualization works with all existing features including lazy loading, pinned options, grouping, select-all/select-filtered bulk actions, and keyboard navigation.import { useState } from "react";
import { MultiSelectMenuSync } from "@servicetitan/anvil2/beta";
import { Button } from "@servicetitan/anvil2";
const options = Array.from({ length: 5000 }, (_, i) => ({
id: i,
label: `Option ${i + 1}`,
}));
const ExampleComponent = () => {
const [selectedOptions, setSelectedOptions] = useState([]);
return (
<MultiSelectMenuSync
virtualize
selectAll
label="Large dataset"
options={options}
value={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
trigger={(props) => (
<Button {...props}>
{selectedOptions.length > 0
? `${selectedOptions.length} selected`
: "Select options"}
</Button>
)}
/>
);
};