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 field family includes two components for different use cases:
  • SelectField — 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)
  • SelectFieldSync — For client-side filtering of static option arrays
Both components provide a searchable dropdown interface and adaptive display modes (popover or dialog).

SelectFieldSync (Static Options)

Use SelectFieldSync when you have a static list of options that can be filtered client-side.
import { useState } from "react";
import { SelectFieldSync } from "@servicetitan/anvil2/beta";

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 (
    <SelectFieldSync
      label="Select an option"
      placeholder="Search options..."
      options={options}
      value={selectedOption}
      onSelectedOptionChange={setSelectedOption}
    />
  );
};

Custom Filtering

By default, SelectFieldSync uses match-sorter to filter options by their label and searchText fields. You can customize filtering in two ways:

Using match-sorter options

<SelectFieldSync
  options={options}
  filter={{ keys: ["label", "searchText"] }}
  // ...other props
/>

Using a custom filter function

<SelectFieldSync
  options={options}
  filter={(options, searchValue) => {
    return options.filter((option) =>
      option.label?.toLowerCase().includes(searchValue.toLowerCase())
    );
  }}
  // ...other props
/>

SelectField (Async Loading)

Use SelectField 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 { SelectField } from "@servicetitan/anvil2/beta";

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

  return (
    <SelectField
      label="Search users"
      placeholder="Type to search..."
      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}
    />
  );
};

Lazy Loading Modes

SelectField supports three lazy loading modes for paginated data:

Page-based Pagination

<SelectField
  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

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

Display Modes

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

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

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

Caching

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

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

Clearing the Cache

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

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

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

  return (
    <>
      <SelectField ref={selectFieldRef} {...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)
<SelectField initialLoad="immediate" {...props} />

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

// Auto (currently equivalent to "immediate")
<SelectField initialLoad="auto" {...props} />

Field States

Error State

Display validation errors using the error prop:
<SelectFieldSync
  label="Category"
  options={options}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  error="Please select a category"
/>

Hint and Description

Provide additional context with hint and description:
<SelectFieldSync
  label="Category"
  options={options}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  hint="Select the most relevant category"
  description="This will be used for filtering"
/>

Required Field

Mark a field as required with the required prop:
<SelectFieldSync
  label="Category"
  options={options}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  required
/>

Disabled and ReadOnly

When disabled is set, users cannot interact with the field.
// Disabled - cannot interact with the field
<SelectFieldSync
  label="Category"
  options={options}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  disabled
/>
When readOnly is set, users can see what options exist but cannot change the current value.
// ReadOnly - can see options but cannot change the current value
<SelectFieldSync
  label="Category"
  options={options}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  readOnly
/>

Prefix and Suffix

Add content before or after the input with prefix and suffix:
<SelectFieldSync
  label="Price Range"
  options={priceRanges}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  prefix="$"
  suffix="USD"
/>

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

<SelectFieldSync
  label="Select an option"
  options={options}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
/>

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:
<SelectField
  label="Books"
  pinned={{
    label: "Favorites",
    options: [
      { id: "fav-1", label: "The Martian" },
      { id: "fav-2", label: "Dune" },
    ],
  }}
  loadOptions={fetchBooks}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
/>

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:
<SelectField
  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}
/>
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:
<SelectField
  label="Books"
  pinned={{
    label: "Your Favorites",
    options: async () => {
      return await fetchFavorites();
    },
    searchReactive: false,
  }}
  loadOptions={fetchBooks}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
/>

Multiple Pinned Sections

Pass an array of pinned section objects:
<SelectField
  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}
/>

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

<SelectFieldSync
  label="Food"
  options={options}
  groupToString={(group) =>
    group === "fruits" ? "Fruits" : "Vegetables"
  }
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
/>

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. With groupSorter, you can apply custom sorting (e.g., alphabetical, numerical priority).This prop is available on SelectFieldSync and non-lazy SelectField:
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 },
];

// With SelectFieldSync
<SelectFieldSync
  label="Items"
  options={options}
  groupToString={(group) => `Priority ${group}`}
  groupSorter={(a, b) => Number(a) - Number(b)}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
/>

// With async SelectField (non-lazy)
<SelectField
  label="Items"
  loadOptions={async () => options}
  groupToString={(group) => `Priority ${group}`}
  groupSorter={(a, b) => Number(a) - Number(b)}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
/>

Mixed Grouped and Ungrouped Options

Options without a group property appear after all grouped sections:
const options = [
  { id: 1, label: "Apple", group: "fruits" },
  { id: 2, label: "Banana", group: "fruits" },
  { id: 3, label: "Other Item" }, // No group - appears last
  { id: 4, label: "Carrot", group: "vegetables" },
];
Last modified on February 11, 2026