Skip to main content
Beta FeatureThis feature is currently in beta, and needs to be imported from @servicetitan/anvil2/beta.While we hope to minimize breaking changes, they may occur due to feedback we receive or other improvements. These will always be documented in the changelog and communicated in Slack.Please reach out in the #ask-designsystem channel with any questions or feedback!

Overview

The select menu family includes two components for different use cases:
  • SelectMenu — 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)
  • SelectMenuSync — 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 closes after an option is selected. Use SelectMenu when you need 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 input? Use SelectField instead.

SelectMenuSync (Static Options)

Use SelectMenuSync when you have a static list of options that can be filtered client-side.
import { useState } from "react";
import { SelectMenuSync } 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 [selectedOption, setSelectedOption] = useState(null);

  return (
    <SelectMenuSync
      label="Options"
      options={options}
      value={selectedOption}
      onSelectedOptionChange={setSelectedOption}
      trigger={(props) => (
        <Button {...props}>
          {selectedOption ? selectedOption.label : "Select an option"}
        </Button>
      )}
    />
  );
};

Filtering and Sorting

By default, SelectMenuSync 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):
<SelectMenuSync
  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:
<SelectMenuSync
  options={options}
  filter={(options, searchValue) => {
    return options.filter((option) =>
      option.label?.toLowerCase().includes(searchValue.toLowerCase())
    );
  }}
  // ...other props
/>

SelectMenu (Async Loading)

Use SelectMenu when options need to be fetched from an API or when dealing with large datasets that require server-side filtering.

Basic Async Loading

import { useState } from "react";
import { SelectMenu } from "@servicetitan/anvil2/beta";
import { Button } from "@servicetitan/anvil2";

const ExampleComponent = () => {
  const [selectedOption, setSelectedOption] = useState(null);

  return (
    <SelectMenu
      label="Users"
      searchPlaceholder="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,
        }));
      }}
      value={selectedOption}
      onSelectedOptionChange={setSelectedOption}
      trigger={(props) => (
        <Button {...props}>
          {selectedOption ? selectedOption.label : "Select a user"}
        </Button>
      )}
    />
  );
};

Lazy Loading Modes

SelectMenu supports three lazy loading modes for paginated data:

Page-based Pagination

<SelectMenu
  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
/>

Offset-based Pagination

<SelectMenu
  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:
<SelectMenu
  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
/>

Display Modes

Control how the options menu is displayed using the displayMenuAs prop:
// Automatically choose based on device (default)
<SelectMenu displayMenuAs="auto" {...props} />

// Always show as popover (not recommended for mobile)
<SelectMenu displayMenuAs="popover" {...props} />

// Always show as dialog
<SelectMenu displayMenuAs="dialog" {...props} />

Popover Width

Control the width of the popover using the width prop:
// Fixed pixel width
<SelectMenu width={300} {...props} />

// CSS string value
<SelectMenu width="20rem" {...props} />

Caching

SelectMenu caches loadOptions results by default. Configure caching behavior:
// Disable caching
<SelectMenu cache={{ enabled: false }} {...props} />

// Configure max cache size (default: 15)
<SelectMenu cache={{ maxSize: 100 }} {...props} />

Clearing the Cache

Use a ref to imperatively clear the cache:
import { useRef } from "react";
import { SelectMenu } from "@servicetitan/anvil2/beta";

const ExampleComponent = () => {
  const selectMenuRef = useRef(null);

  const handleRefresh = () => {
    selectMenuRef.current?.clearCache();
  };

  return (
    <>
      <SelectMenu ref={selectMenuRef} {...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 { SelectMenu } from "@servicetitan/anvil2/beta";

const ExampleComponent = () => {
  const selectMenuRef = useRef(null);

  const handleDataSourceChange = () => {
    selectMenuRef.current?.invalidate();
  };

  return (
    <SelectMenu ref={selectMenuRef} {...props} />
  );
};
SelectMenuSync 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")
<SelectMenu initialLoad="immediate" {...props} />

// Load when user opens the dropdown
<SelectMenu initialLoad="open" {...props} />

// Auto (currently equivalent to "immediate")
<SelectMenu initialLoad="auto" {...props} />
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 { SelectMenuSync } from "@servicetitan/anvil2/beta";
import { Button } from "@servicetitan/anvil2";

const options = [
  { id: 1, label: "Small" },
  { id: 2, label: "Medium" },
  { id: 3, label: "Large" },
];

const ExampleComponent = () => {
  const [selectedOption, setSelectedOption] = useState(null);

  return (
    <SelectMenuSync
      disableSearch
      label="Size"
      options={options}
      value={selectedOption}
      onSelectedOptionChange={setSelectedOption}
      trigger={(props) => (
        <Button {...props}>
          {selectedOption ? selectedOption.label : "Select a size"}
        </Button>
      )}
    />
  );
};

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" },
];

<SelectMenuSync
  label="Select an option"
  options={options}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  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:
<SelectMenu
  label="Books"
  pinned={{
    label: "Favorites",
    options: [
      { id: "fav-1", label: "The Martian" },
      { id: "fav-2", label: "Dune" },
    ],
  }}
  loadOptions={fetchBooks}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  trigger={(props) => <Button {...props}>Select a book</Button>}
/>

Dynamic Pinned Options

Pass a function as options to compute pinned options based on the current search value:
<SelectMenu
  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={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  trigger={(props) => <Button {...props}>Select a book</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:
<SelectMenu
  label="Books"
  pinned={{
    label: "Your Favorites",
    options: async () => {
      return await fetchFavorites();
    },
    searchReactive: false,
  }}
  loadOptions={fetchBooks}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  trigger={(props) => <Button {...props}>Select a book</Button>}
/>

Multiple Pinned Sections

Pass an array of pinned section objects:
<SelectMenu
  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={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  trigger={(props) => <Button {...props}>Select a book</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" },
];

<SelectMenuSync
  label="Food"
  options={options}
  groupToString={(group) =>
    group === "fruits" ? "Fruits" : "Vegetables"
  }
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  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 SelectMenuSync and non-lazy SelectMenu:
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 },
];

<SelectMenuSync
  label="Items"
  options={options}
  groupToString={(group) => `Priority ${group}`}
  groupSorter={(a, b) => Number(a) - Number(b)}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  trigger={(props) => <Button {...props}>Select item</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.
import { useState } from "react";
import { SelectMenuSync } 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 [selectedOption, setSelectedOption] = useState(null);

  return (
    <SelectMenuSync
      virtualize
      label="Large dataset"
      options={options}
      value={selectedOption}
      onSelectedOptionChange={setSelectedOption}
      trigger={(props) => (
        <Button {...props}>
          {selectedOption ? selectedOption.label : "Select an option"}
        </Button>
      )}
    />
  );
};
Last modified on March 24, 2026