> ## Documentation Index
> Fetch the complete documentation index at: https://anvil.servicetitan.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Single Select Field - Web

> Select Field is a form element that allows users to search, filter, and select a single value from a list of options.

> [!NOTE]
>
> For development, import `SelectField` from
> `@servicetitan/anvil2`. For design, find it in the
> [Anvil2 file](https://www.figma.com/design/xaNQsvRKczoxu7pj0M6QT8/Anvil2-v3?node-id=2168-30827).

> [!NOTE]
>
> Not sure if you should use Select Field or Combobox? Select Field and Multi Select Field are the replacements for [Combobox](https://anvil.servicetitan.com/docs/components/combobox/design).

selectfield-playground

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/selectfield-playground?file=src/App.tsx)

```tsx lines theme={null}
import { useEffect, useRef, useState } from "react";
import { SelectFieldSync, type SelectFieldOption } from "@servicetitan/anvil2";

const options: SelectFieldOption[] = [
  { id: 1, label: "The Martian by Andy Weir" },
  { id: 2, label: "Gone Girl by Gillian Flynn" },
  { id: 3, label: "Mistborn: The Final Empire by Brandon Sanderson" },
  { id: 4, label: "Murder on the Orient Express by Agatha Christie" },
  { id: 5, label: "Dune by Frank Herbert" },
  { id: 6, label: "Beloved by Toni Morrison" },
  { id: 7, label: "Atomic Habits by James Clear" },
  { id: 8, label: "Sapiens by Yuval Noah Harari" },
];

function App() {
  const [value, setValue] = useState<SelectFieldOption | null>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const trigger = containerRef.current?.querySelector("input");
    trigger?.click();
  }, []);

  return (
    <div ref={containerRef} style={{ minWidth: "384px", minHeight: "380px" }}>
      <SelectFieldSync
        label="Favorite book"
        placeholder="Search books..."
        options={options}
        value={value}
        onSelectedOptionChange={setValue}
      />
    </div>
  );
}

export default App;
```

## Anatomy

The Select Field consists of eight elements that work together to allow users to search and select a single value from a list of options.

![Select Field anatomy](https://mintcdn.com/servicetitan/yMyP2mRFjq4alcb1/images/docs/web/components/select-field/single/Anatomy.png?fit=max\&auto=format\&n=yMyP2mRFjq4alcb1\&q=85\&s=cf22794d9dbeb7360e96463fa9c1fd46)

1. Placeholder text
2. Popover
3. Option
4. Selected value
5. Selected options overflow
6. Clear button (Optional)
7. Section header
8. Rich content option

## Options

The Select Field supports the following configurations to accommodate a wide range of selection scenarios.

### Selection options

#### Search

selectfield-search

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/selectfield-search?file=src/App.tsx)

```tsx lines theme={null}
import { useEffect, useRef, useState } from "react";
import { SelectFieldSync, type SelectFieldOption } from "@servicetitan/anvil2";

const options: SelectFieldOption[] = [
  { id: 1, label: "The Martian by Andy Weir" },
  { id: 2, label: "Gone Girl by Gillian Flynn" },
  { id: 3, label: "Mistborn: The Final Empire by Brandon Sanderson" },
  { id: 4, label: "Murder on the Orient Express by Agatha Christie" },
  { id: 5, label: "Dune by Frank Herbert" },
  { id: 6, label: "Beloved by Toni Morrison" },
  { id: 7, label: "Atomic Habits by James Clear" },
  { id: 8, label: "Sapiens by Yuval Noah Harari" },
];

function App() {
  const [value, setValue] = useState<SelectFieldOption | null>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const trigger = containerRef.current?.querySelector("input");
    trigger?.click();
  }, []);

  return (
    <div ref={containerRef} style={{ minWidth: "384px", minHeight: "380px" }}>
      <SelectFieldSync
        label="Favorite book"
        placeholder="Search books..."
        options={options}
        value={value}
        onSelectedOptionChange={setValue}
      />
    </div>
  );
}

export default App;
```

By default, Select Field provides a searchable text input that filters options as users type.

#### Select-only

selectfield-select-only

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/selectfield-select-only?file=src/App.tsx)

```tsx lines theme={null}
import { useEffect, useRef, useState } from "react";
import { SelectFieldSync, type SelectFieldOption } from "@servicetitan/anvil2";

const options: SelectFieldOption[] = [
  { id: 1, label: "The Martian by Andy Weir" },
  { id: 2, label: "Gone Girl by Gillian Flynn" },
  { id: 3, label: "Mistborn: The Final Empire by Brandon Sanderson" },
  { id: 4, label: "Murder on the Orient Express by Agatha Christie" },
  { id: 5, label: "Dune by Frank Herbert" },
  { id: 6, label: "Beloved by Toni Morrison" },
  { id: 7, label: "Atomic Habits by James Clear" },
  { id: 8, label: "Sapiens by Yuval Noah Harari" },
];

function App() {
  const [value, setValue] = useState<SelectFieldOption | null>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const trigger = containerRef.current?.querySelector('[role="combobox"]');
    if (trigger instanceof HTMLElement) trigger.click();
  }, []);

  return (
    <div ref={containerRef} style={{ minWidth: "384px", minHeight: "380px" }}>
      <SelectFieldSync
        disableSearch
        label="Favorite book"
        placeholder="Select a book..."
        options={options}
        value={value}
        onSelectedOptionChange={setValue}
      />
    </div>
  );
}

export default App;
```

When search adds no value — such as with short, well-known option lists — disable it to present a simpler select-only trigger. The select-only mode uses the listbox ARIA pattern instead of combobox.

### Menu display options

Select Field displays options in a popover by default on desktop and a dialog on mobile. Override this adaptive behavior by setting the display mode explicitly to always use a popover or always use a dialog.

#### In a Popover

selectfield-display-popover

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/selectfield-display-popover?file=src/App.tsx)

```tsx lines theme={null}
import { useEffect, useRef, useState } from "react";
import { SelectFieldSync, type SelectFieldOption } from "@servicetitan/anvil2";

const options: SelectFieldOption[] = [
  { id: 1, label: "The Martian by Andy Weir" },
  { id: 2, label: "Gone Girl by Gillian Flynn" },
  { id: 3, label: "Mistborn: The Final Empire by Brandon Sanderson" },
  { id: 4, label: "Murder on the Orient Express by Agatha Christie" },
  { id: 5, label: "Dune by Frank Herbert" },
];

function App() {
  const [value, setValue] = useState<SelectFieldOption | null>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const timer = setTimeout(() => {
      const trigger = containerRef.current?.querySelector("input");
      trigger?.focus();
      trigger?.click();
    }, 100);
    return () => clearTimeout(timer);
  }, []);

  return (
    <div ref={containerRef} style={{ minWidth: "384px", minHeight: "380px" }}>
      <SelectFieldSync
        displayMenuAs="popover"
        label="Favorite book"
        placeholder="Search books..."
        options={options}
        value={value}
        onSelectedOptionChange={setValue}
      />
    </div>
  );
}

export default App;
```

#### In a Dialog

selectfield-display-dialog

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/selectfield-display-dialog?file=src/App.tsx)

```tsx lines theme={null}
import { useEffect, useRef, useState } from "react";
import { SelectFieldSync, type SelectFieldOption } from "@servicetitan/anvil2";

const options: SelectFieldOption[] = [
  { id: 1, label: "The Martian by Andy Weir" },
  { id: 2, label: "Gone Girl by Gillian Flynn" },
  { id: 3, label: "Mistborn: The Final Empire by Brandon Sanderson" },
  { id: 4, label: "Murder on the Orient Express by Agatha Christie" },
  { id: 5, label: "Dune by Frank Herbert" },
  { id: 6, label: "Beloved by Toni Morrison" },
  { id: 7, label: "Atomic Habits by James Clear" },
  { id: 8, label: "Sapiens by Yuval Noah Harari" },
];

function App() {
  const [value, setValue] = useState<SelectFieldOption | null>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const timer = setTimeout(() => {
      const trigger = containerRef.current?.querySelector("input");
      trigger?.focus();
      trigger?.click();
    }, 100);
    return () => clearTimeout(timer);
  }, []);

  return (
    <div ref={containerRef} style={{ minWidth: "384px", minHeight: "380px" }}>
      <SelectFieldSync
        displayMenuAs="dialog"
        label="Favorite book"
        placeholder="Search books..."
        options={options}
        value={value}
        onSelectedOptionChange={setValue}
      />
    </div>
  );
}

export default App;
```

### Sections

selectfield-sections

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/selectfield-sections?file=src/App.tsx)

```tsx lines theme={null}
import { useEffect, useRef, useState } from "react";
import { SelectFieldSync, type SelectFieldOption } from "@servicetitan/anvil2";

const options: SelectFieldOption[] = [
  { id: 1, label: "The Great Gatsby", group: "fiction" },
  { id: 2, label: "To Kill a Mockingbird", group: "fiction" },
  { id: 3, label: "1984", group: "fiction" },
  { id: 4, label: "Sapiens", group: "non-fiction" },
  { id: 5, label: "Thinking, Fast and Slow", group: "non-fiction" },
  { id: 6, label: "The Martian", group: "sci-fi" },
  { id: 7, label: "Dune", group: "sci-fi" },
  { id: 8, label: "Foundation", group: "sci-fi" },
];

const groupLabels: Record<string, string> = {
  fiction: "Fiction",
  "non-fiction": "Non-Fiction",
  "sci-fi": "Science Fiction",
};

function App() {
  const [value, setValue] = useState<SelectFieldOption | null>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const timer = setTimeout(() => {
      const trigger = containerRef.current?.querySelector("input");
      trigger?.focus();
      trigger?.click();
    }, 100);
    return () => clearTimeout(timer);
  }, []);

  return (
    <div ref={containerRef} style={{ minWidth: "384px", minHeight: "560px" }}>
      <SelectFieldSync
        label="Books by genre"
        placeholder="Search books..."
        options={options}
        groupToString={(group) => groupLabels[group as string] ?? String(group)}
        value={value}
        onSelectedOptionChange={setValue}
      />
    </div>
  );
}

export default App;
```

Sections divide options into labeled groups, helping users scan and identify related items. Every section requires a label.

#### Pinned sections

Sections can be pinned to the top of the list. Pinned sections are useful for surfacing frequently used options, recent selections, or AI-powered suggestions without requiring the user to search for them.

### Rich content

selectfield-rich-content

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/selectfield-rich-content?file=src/App.tsx)

```tsx lines theme={null}
import { useEffect, useRef, useState } from "react";
import { SelectField, type SelectFieldOption } from "@servicetitan/anvil2";
import GroupIcon from "@servicetitan/anvil2/assets/icons/material/round/groups.svg";

const options: SelectFieldOption[] = [
  {
    id: 1,
    label: "Label only",
  },
  {
    id: 2,
    label: "Label + icon",
    content: {
      icon: { svg: GroupIcon },
    },
  },
  {
    id: 3,
    label: "Title + description",
    content: {
      title: "Title + description",
      description: "A short description providing extra context",
    },
  },
  {
    id: 4,
    label: "Title + description + icon",
    content: {
      title: "Title + description + icon",
      description: "A short description providing extra context",
      icon: { svg: GroupIcon },
    },
  },
  {
    id: 5,
    label: "Title + chips",
    content: {
      title: "Title + chips",
      chips: [{ label: "Tag A" }, { label: "Tag B", color: "#F97316" }],
    },
  },
  {
    id: 6,
    label: "Title + chips + icon",
    content: {
      title: "Title + chips + icon",
      chips: [{ label: "Tag A" }, { label: "Tag B", color: "#F97316" }],
      icon: { svg: GroupIcon },
    },
  },
  {
    id: 7,
    label: "Title + description + avatar",
    content: {
      title: "Title + description + avatar",
      description: "Option with an avatar",
      avatar: { name: "Jane Doe", color: "#6366F1" },
    },
  },
  {
    id: 8,
    label: "Title + description + avatar + icon",
    content: {
      title: "Title + description + avatar + icon",
      description: "Option with an avatar",
      avatar: { name: "Jane Doe", color: "#6366F1" },
      icon: { svg: GroupIcon },
    },
  },
  {
    id: 9,
    label: "Title + description + chips",
    content: {
      title: "Title + description + chips",
      description: "Option with chip tags",
      chips: [{ label: "Tag A" }, { label: "Tag B", color: "#F97316" }],
    },
  },
  {
    id: 10,
    label: "Title + description + chips + icon",
    content: {
      title: "Title + description + chips + icon",
      description: "Option with chip tags",
      chips: [{ label: "Tag A" }, { label: "Tag B", color: "#F97316" }],
      icon: { svg: GroupIcon },
    },
  },
];

function App() {
  const [value, setValue] = useState<SelectFieldOption | null>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const timer = setTimeout(() => {
      const trigger = containerRef.current?.querySelector("input");
      trigger?.focus();
      trigger?.click();
    }, 100);
    return () => clearTimeout(timer);
  }, []);

  return (
    <div ref={containerRef} style={{ minWidth: "384px", minHeight: "420px" }}>
      <SelectField
        label="Rich content options"
        placeholder="Search options..."
        loadOptions={() => options}
        value={value}
        onSelectedOptionChange={setValue}
      />
    </div>
  );
}

export default App;
```

Options support rich content including titles, descriptions, avatars, icons, and chips. Rich content helps users distinguish between similarly named items or convey additional metadata at a glance.

#### Icons

An icon appears on the right side of the option, after the title, description, and chips. Icons work alongside every other content type, so an option can show an avatar on the left and an icon on the right.

selectfield-rich-content-icons

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/selectfield-rich-content-icons?file=src/App.tsx)

```tsx lines theme={null}
import { useEffect, useRef, useState } from "react";
import { SelectField, type SelectFieldOption } from "@servicetitan/anvil2";
import GroupIcon from "@servicetitan/anvil2/assets/icons/material/round/groups.svg";

const options: SelectFieldOption[] = [
  {
    id: "install-crew",
    label: "Install crew",
    content: {
      description: "6 technicians",
      icon: { svg: GroupIcon },
    },
  },
  {
    id: "service-team",
    label: "Service team",
    content: {
      description: "12 technicians",
      icon: { svg: GroupIcon },
    },
  },
  {
    id: "maintenance-team",
    label: "Maintenance team",
    content: {
      description: "8 technicians",
      icon: { svg: GroupIcon },
    },
  },
  {
    id: "commercial-team",
    label: "Commercial team",
    content: {
      description: "5 technicians",
      icon: { svg: GroupIcon },
    },
  },
];

function App() {
  const [value, setValue] = useState<SelectFieldOption | null>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const timer = setTimeout(() => {
      const trigger = containerRef.current?.querySelector("input");
      trigger?.focus();
      trigger?.click();
    }, 100);
    return () => clearTimeout(timer);
  }, []);

  return (
    <div ref={containerRef} style={{ minWidth: "384px", minHeight: "340px" }}>
      <SelectField
        label="Assigned team"
        placeholder="Search teams..."
        loadOptions={() => options}
        value={value}
        onSelectedOptionChange={setValue}
      />
    </div>
  );
}

export default App;
```

### Lazy loading options

selectfield-lazy-loading

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/selectfield-lazy-loading?file=src/App.tsx)

```tsx lines theme={null}
import { useEffect, useRef, useState } from "react";
import { SelectField, type SelectFieldOption } from "@servicetitan/anvil2";

const allBooks = Array.from({ length: 100 }, (_, i) => ({
  id: `book-${i + 1}`,
  label: `Book ${i + 1}`,
}));

function App() {
  const [value, setValue] = useState<SelectFieldOption | null>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const trigger = containerRef.current?.querySelector("input");
    trigger?.click();
  }, []);

  return (
    <div ref={containerRef} style={{ minWidth: "384px", minHeight: "420px" }}>
      <SelectField
        lazy="page"
        lazyOptions={{ pageSize: 10 }}
        loadOptions={async (searchValue, pageNumber, pageSize) => {
          await new Promise((r) => setTimeout(r, 300));

          const filtered = allBooks.filter((b) =>
            b.label.toLowerCase().includes(searchValue.toLowerCase()),
          );
          const start = pageNumber * pageSize;
          const page = filtered.slice(start, start + pageSize);

          return {
            options: page,
            hasMore: start + page.length < filtered.length,
          };
        }}
        label="Books (lazy loading)"
        placeholder="Search books..."
        value={value}
        onSelectedOptionChange={setValue}
      />
    </div>
  );
}

export default App;
```

For large datasets, options load on demand as users scroll through the list. Three pagination strategies are supported: page-based, offset-based, and group-based. Results are cached by default to avoid redundant requests.

### Field Options

#### Sizes

selectfield-sizes

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/selectfield-sizes?file=src/App.tsx)

```tsx lines theme={null}
import { useState } from "react";
import { Flex } from "@servicetitan/anvil2";
import { SelectFieldSync, type SelectFieldOption } from "@servicetitan/anvil2";

const options: SelectFieldOption[] = [
  { id: 1, label: "The Martian by Andy Weir" },
  { id: 2, label: "Gone Girl by Gillian Flynn" },
  { id: 3, label: "Dune by Frank Herbert" },
  { id: 4, label: "Beloved by Toni Morrison" },
  { id: 5, label: "Atomic Habits by James Clear" },
];

function App() {
  const [smallValue, setSmallValue] = useState<SelectFieldOption | null>(null);
  const [mediumValue, setMediumValue] = useState<SelectFieldOption | null>(
    null,
  );
  const [largeValue, setLargeValue] = useState<SelectFieldOption | null>(null);

  return (
    <Flex direction="column" gap="6" style={{ maxWidth: 320 }}>
      <SelectFieldSync
        size="small"
        label="Small"
        placeholder="Search books..."
        options={options}
        value={smallValue}
        onSelectedOptionChange={setSmallValue}
      />
      <SelectFieldSync
        size="medium"
        label="Medium (default)"
        placeholder="Search books..."
        options={options}
        value={mediumValue}
        onSelectedOptionChange={setMediumValue}
      />
      <SelectFieldSync
        size="large"
        label="Large"
        placeholder="Search books..."
        options={options}
        value={largeValue}
        onSelectedOptionChange={setLargeValue}
      />
    </Flex>
  );
}

export default App;
```

Three sizes are available: small, medium, and large. Choose a size that aligns with the surrounding form layout and density.

#### Prefix and suffix

selectfield-prefix-suffix

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/selectfield-prefix-suffix?file=src/App.tsx)

```tsx lines theme={null}
import { useState } from "react";
import { SelectFieldSync, type SelectFieldOption } from "@servicetitan/anvil2";

const options: SelectFieldOption[] = [
  { id: 1, label: "0-25" },
  { id: 2, label: "25-50" },
  { id: 3, label: "50-100" },
  { id: 4, label: "100+" },
];

function App() {
  const [value, setValue] = useState<SelectFieldOption | null>(null);

  return (
    <div style={{ width: 320 }}>
      <SelectFieldSync
        label="Price range"
        placeholder="Select price range..."
        options={options}
        prefix="$"
        suffix="USD"
        value={value}
        onSelectedOptionChange={setValue}
      />
    </div>
  );
}

export default App;
```

Add content before or after the input to provide additional context, such as currency symbols or units.

## Behavior

The Select Field responds to user interaction with search filtering and distinct visual states.

### Visual states

selectfield-visual-states

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/selectfield-visual-states?file=src/App.tsx)

```tsx lines theme={null}
import { useState } from "react";
import { Flex } from "@servicetitan/anvil2";
import { SelectFieldSync, type SelectFieldOption } from "@servicetitan/anvil2";

const options: SelectFieldOption[] = [
  { id: 1, label: "The Martian by Andy Weir" },
  { id: 2, label: "Gone Girl by Gillian Flynn" },
  { id: 3, label: "Dune by Frank Herbert" },
  { id: 4, label: "Beloved by Toni Morrison" },
  { id: 5, label: "Atomic Habits by James Clear" },
];

function App() {
  const [value, setValue] = useState<SelectFieldOption | null>(null);

  return (
    <Flex direction="column" gap="6" style={{ width: 320 }}>
      <SelectFieldSync
        label="Disabled"
        placeholder="Search books..."
        options={options}
        value={options[0]}
        onSelectedOptionChange={setValue}
        disabled
      />
      <SelectFieldSync
        label="Read-only"
        placeholder="Search books..."
        options={options}
        value={options[0]}
        onSelectedOptionChange={setValue}
        readOnly
      />
      <SelectFieldSync
        label="Error"
        placeholder="Search books..."
        options={options}
        value={value}
        onSelectedOptionChange={setValue}
        error="Please select a book"
      />
    </Flex>
  );
}

export default App;
```

Select Field supports standard form field states. Disabled prevents all interaction. Read-only allows viewing options without changing the selection. Error highlights validation issues with a message. Required marks the field as mandatory with a visual indicator.

### Search and filtering

selectfield-search-filtering

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/selectfield-search-filtering?file=src/App.tsx)

```tsx lines theme={null}
import { useEffect, useRef, useState } from "react";
import { SelectFieldSync, type SelectFieldOption } from "@servicetitan/anvil2";

const options: SelectFieldOption[] = [
  { id: 1, label: "The Martian by Andy Weir" },
  { id: 2, label: "Gone Girl by Gillian Flynn" },
  { id: 3, label: "Mistborn: The Final Empire by Brandon Sanderson" },
  { id: 4, label: "Murder on the Orient Express by Agatha Christie" },
  { id: 5, label: "Dune by Frank Herbert" },
  { id: 6, label: "Beloved by Toni Morrison" },
  { id: 7, label: "Atomic Habits by James Clear" },
  { id: 8, label: "Sapiens by Yuval Noah Harari" },
];

function App() {
  const [value, setValue] = useState<SelectFieldOption | null>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const trigger = containerRef.current?.querySelector("input");
    trigger?.click();
  }, []);

  return (
    <div ref={containerRef} style={{ minWidth: "384px", minHeight: "380px" }}>
      <SelectFieldSync
        label="Favorite book"
        placeholder="Try typing to filter..."
        options={options}
        value={value}
        onSelectedOptionChange={setValue}
      />
    </div>
  );
}

export default App;
```

In combobox mode, typing in the input filters options in real time. Results are ranked by match quality so the most relevant options appear first. Search input is automatically debounced to reduce unnecessary requests when loading options asynchronously.

## Usage Guidelines

| Standard | Requirement |
| - | - |
| DoSingle option from a large set | Use Select Field when users pick one value from a medium-to-large list of options. |
| DoTypeahead search | Keep the default search input so options filter as users type. For short, well-known lists, use [select-only](#select-only) instead. |
| DoServer-loaded, paginated options | Load options on demand as users scroll when the dataset is large; see [Lazy loading options](#lazy-loading-options). |
| DoGrouped or pinned options | Use labeled [sections](#sections) to group related options, and pin a section to surface frequent, recent, or suggested options. |
| DoSimplified select experience | Use Select Field for an opinionated, ready-made select in a form instead of building a custom one. |
| Don'tNavigational contexts | Don't use Select Field to move between pages or views. It is for choosing a value, such as in a form. |

### Alternatives

When there are only a few items to choose from, a [radio](https://anvil.servicetitan.com/docs/components/radio/web.md) or [checkbox](https://anvil.servicetitan.com/docs/components/checkbox/web.md) group may be more appropriate. When a particularly complex selection interaction is needed, a [Listbox](https://anvil.servicetitan.com/docs/components/listbox/web.md) in a special layout may be preferred.

#### Select Field vs Select Trigger

Select Field displays options in a built-in Popover or Dialog with integrated search and filtering. [Select Trigger](https://anvil.servicetitan.com/docs/components/select-trigger/web.md) opens a custom overlay — typically a Dialog — that the implementor fully controls.

### How to Use

#### Labels and help

For guidance on labels, placeholders, and help text, refer to the [Form pattern](https://anvil.servicetitan.com/docs/patterns/forms/web.md).

#### Sorting options

Select Field does not prescribe how options are sorted. Common sorting strategies include alphabetical, time-based, and categorical. Choose a sort order that matches user expectations for the data.

## Content

Content within the Select Field should clearly communicate available options and help users identify the correct selection.

* Use clear, concise option labels that users recognize immediately
* Add descriptions to rich content options when labels alone are insufficient to distinguish items
* Use pinned sections to surface frequently used or contextually relevant options

For additional content guidance, refer to the [Form pattern](https://anvil.servicetitan.com/docs/patterns/forms/web.md).

## Keyboard Interaction

Users can navigate the Select Field using standard keyboard controls. Keyboard behavior differs between combobox mode (search enabled) and select mode (search disabled).

### Search enabled

#### Text field

| Key | Interaction |
| - | - |
| Down Arrow | If the listbox is displayed, moves visual focus to the first option. If the listbox is not displayed, opens it and moves visual focus to the first option. DOM focus remains on the text field. |
| Up Arrow | If the listbox is displayed, moves visual focus to the last option. If the listbox is not displayed, opens it and moves visual focus to the last option. DOM focus remains on the text field. |
| Enter | Closes the listbox if displayed. |
| Escape | If the listbox is displayed, closes it. If the listbox is not displayed, clears the text field. |

#### Popup

| Key | Interaction |
| - | - |
| Enter | Sets the text field value to the focused option and closes the popup. |
| Escape | Closes the popup and returns visual focus to the text field. |
| Down Arrow | Moves visual focus to the next option. Wraps from last to first. |
| Up Arrow | Moves visual focus to the previous option. Wraps from first to last. |

### Search disabled

| Key | Interaction |
| - | - |
| Enter / Space | Opens the listbox. |
| Down Arrow | Opens the listbox and moves focus to the first option. |
| Up Arrow | Opens the listbox and moves focus to the last option. |
| Escape | Closes the listbox. |

### Accessibility

Anvil provides accessibility support for Select Field out of the box. In combobox mode, the component follows the combobox ARIA pattern. In select mode, it follows the listbox ARIA pattern.

For more guidance on building accessible form controls, see [custom component best practices](https://anvil.servicetitan.com/docs/resources/contributing/accessible-custom-components.md).

## Code

> [!WARNING]
>
> Importing `SelectField` from `@servicetitan/anvil2/beta` is
> deprecated. Import from `@servicetitan/anvil2` instead. The beta export will
> be removed in Anvil2 4.0.

**Implementation**

## 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.

```tsx theme={null}
import { useState } from "react";
import { SelectFieldSync } 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 (
    <SelectFieldSync
      label="Select an option"
      placeholder="Search options..."
      options={options}
      value={selectedOption}
      onSelectedOptionChange={setSelectedOption}
    />
  );
};
```

### Filtering and Sorting

By default, `SelectFieldSync` uses [match-sorter](https://github.com/kentcdodds/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](https://github.com/kentcdodds/match-sorter#options) to customize the default filtering and sorting behavior (e.g., change which keys are matched or adjust ranking):

```tsx theme={null}
<SelectFieldSync
  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:

```tsx theme={null}
<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

```tsx theme={null}
import { useState } from "react";
import { SelectField } from "@servicetitan/anvil2";

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

```tsx theme={null}
<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

```tsx theme={null}
<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:

```tsx theme={null}
<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:

```tsx theme={null}
// 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:

```tsx theme={null}
// 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:

```tsx theme={null}
import { useRef } from "react";
import { SelectField } from "@servicetitan/anvil2";

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

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

  return (
    <>
      <SelectField ref={selectFieldRef} {...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:

```tsx theme={null}
import { useRef } from "react";
import { SelectField } from "@servicetitan/anvil2";

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

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

  return (
    <SelectField ref={selectFieldRef} {...props} />
  );
};
```

> [!NOTE]
>
> `SelectFieldSync` handles this automatically when its `options` prop changes.

## Initial Load Behavior

Control when options are first loaded with the `initialLoad` prop:

```tsx theme={null}
// 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:

```tsx theme={null}
<SelectFieldSync
  label="Category"
  options={options}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  error="Please select a category"
/>
```

### Hint and Description

Provide additional context with `hint` and `description`:

```tsx theme={null}
<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:

```tsx theme={null}
<SelectFieldSync
  label="Category"
  options={options}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  required
/>
```

### Disabled and ReadOnly

When `disabled` is set, users cannot interact with the field.

```tsx theme={null}
// 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.

```tsx theme={null}
// 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`:

```tsx theme={null}
<SelectFieldSync
  label="Price Range"
  options={priceRanges}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  prefix="$"
  suffix="USD"
/>
```

### Markdown in labels

The `label` prop supports inline markdown: bold (`**text**`), italic (`*text*`), bold and italic (`***text***`), highlight (`==text==`), and code (`` `text` ``).

selectfield-markdownlabel

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/selectfield-markdownlabel?file=src/App.tsx)

```tsx lines expandable theme={null}
import { useState } from "react";
import { Flex } from "@servicetitan/anvil2";
import { SelectFieldSync, type SelectFieldOption } from "@servicetitan/anvil2";

const options = [
  { id: 1, label: "Option A" },
  { id: 2, label: "Option B" },
  { id: 3, label: "Option C" },
];

function App() {
  const [value, setValue] = useState<SelectFieldOption | null>(null);

  return (
    <Flex direction="column" gap="4" style={{ maxWidth: 400 }}>
      <SelectFieldSync
        label="**Bold** label"
        placeholder="Bold"
        options={options}
        value={value}
        onSelectedOptionChange={setValue}
      />
      <SelectFieldSync
        label="*Italic* label"
        placeholder="Italic"
        options={options}
        value={value}
        onSelectedOptionChange={setValue}
      />
      <SelectFieldSync
        label="***Bold and italic*** label"
        placeholder="Bold and italic"
        options={options}
        value={value}
        onSelectedOptionChange={setValue}
      />
      <SelectFieldSync
        label="==Highlight== label"
        placeholder="Highlight"
        options={options}
        value={value}
        onSelectedOptionChange={setValue}
      />
      <SelectFieldSync
        label="`Code` label"
        placeholder="Code"
        options={options}
        value={value}
        onSelectedOptionChange={setValue}
      />
    </Flex>
  );
}

export default App;
```

### Hide the label

Use `hideLabel` to visually hide the label. The `label` string is converted to an `aria-label` on the input so it remains accessible to screen readers — any inline markdown is stripped to plain text.

selectfield-hidelabel

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/selectfield-hidelabel?file=src/App.tsx)

```tsx lines expandable theme={null}
import { useState } from "react";
import { Flex } from "@servicetitan/anvil2";
import { SelectFieldSync, type SelectFieldOption } from "@servicetitan/anvil2";

const options = [
  { id: 1, label: "Option A" },
  { id: 2, label: "Option B" },
  { id: 3, label: "Option C" },
];

function App() {
  const [value, setValue] = useState<SelectFieldOption | null>(null);

  return (
    <Flex direction="row" gap="2" style={{ maxWidth: 500 }}>
      <SelectFieldSync
        label="First option"
        hideLabel
        placeholder="First option"
        options={options}
        value={value}
        onSelectedOptionChange={setValue}
      />
      <SelectFieldSync
        label="Second option"
        hideLabel
        placeholder="Second option"
        options={options}
        value={value}
        onSelectedOptionChange={setValue}
      />
    </Flex>
  );
}

export default App;
```

## Disabled Options

Individual options can be disabled by setting `disabled: true` on the option:

```tsx theme={null}
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:

```tsx theme={null}
<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:

```tsx theme={null}
<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:

```tsx theme={null}
<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:

```tsx theme={null}
<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:

```tsx theme={null}
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`:

> [!WARNING]
>
> 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.

```tsx theme={null}
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:

```tsx theme={null}
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" },
];
```

## 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 as the browser must lay out and paint thousands of elements. 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 (e.g., items with descriptions are heavier). For smaller lists, the default rendering path is sufficient and avoids the overhead of virtual positioning.

Virtualization works with all existing features including lazy loading, pinned options, grouping, and keyboard navigation.

```tsx theme={null}
import { useState } from "react";
import { SelectFieldSync } from "@servicetitan/anvil2";

const options = Array.from({ length: 5000 }, (_, i) => ({
  id: i,
  label: `Option ${i + 1}`,
}));

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

  return (
    <SelectFieldSync
      virtualize
      label="Large dataset"
      placeholder="Search options..."
      options={options}
      value={selectedOption}
      onSelectedOptionChange={setSelectedOption}
    />
  );
};
```

## Adding New Items

Provide an `onAddNewItem` handler to render an "Add new item" button below the option list. This affordance lets users create a value that does not exist yet — a new customer, tag, or category — quickly from the field.

The button sits in a footer region inside the menu. Clicking or activating it via keyboard closes the menu and invokes the `onAddNewItem` handler with the current search text.

From here, it is your responsibility to launch a `Dialog` (or `Drawer`, or any other overlay) for collecting the new item's details, append the result to the option source, invalidate the cache, and update selection state.

You may pair `onAddNewItem` with `addItemLabel` to control the button text. The label accepts a static node or a function of the current search text — for example, returning `Add "foo"` when the user has typed `foo`.

When `addItemLabel` is a function, return `null` (or `undefined`) to hide the button for the current search text. Use this when the affordance should only appear for valid input — for example, showing "Add email" only after a well-formed address is typed:

```tsx theme={null}
addItemLabel={(searchText) =>
  isValidEmail(searchText) ? `Add "${searchText}"` : null
}
onAddNewItem={openAddEmailDialog}
```

selectfield-add-new-item

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/selectfield-add-new-item?file=src/App.tsx)

```tsx lines expandable theme={null}
import { useEffect, useRef, useState } from "react";
import {
  SelectField,
  type SelectFieldHandle,
  type SelectFieldOption,
} from "@servicetitan/anvil2";
import { Button, Dialog, Flex, TextField } from "@servicetitan/anvil2";

type Customer = SelectFieldOption & { phone: string };

function App() {
  const [customers, setCustomers] = useState<Customer[]>([
    { id: "1", label: "Acme HVAC", phone: "(555) 123-4567" },
    { id: "2", label: "Bluebird Plumbing", phone: "(555) 234-5678" },
    { id: "3", label: "Carolina Electric", phone: "(555) 345-6789" },
  ]);
  const [selected, setSelected] = useState<Customer | null>(null);
  const selectRef = useRef<SelectFieldHandle>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  const [dialogOpen, setDialogOpen] = useState(false);
  const [draftName, setDraftName] = useState("");
  const [draftPhone, setDraftPhone] = useState("");

  // Open the menu on mount so the Add-new button is visible in screenshots.
  useEffect(() => {
    const timer = setTimeout(() => {
      const trigger = containerRef.current?.querySelector("input");
      trigger?.focus();
      trigger?.click();
    }, 100);
    return () => clearTimeout(timer);
  }, []);

  const openCustomerDialog = (initialName: string) => {
    setDraftName(initialName);
    setDraftPhone("");
    setDialogOpen(true);
  };

  const saveCustomer = () => {
    const newCustomer: Customer = {
      id: crypto.randomUUID(),
      label: draftName,
      phone: draftPhone,
    };
    setCustomers((prev) => [...prev, newCustomer]);
    setSelected(newCustomer);
    selectRef.current?.invalidate();
    setDialogOpen(false);
  };

  return (
    <div ref={containerRef} style={{ minWidth: "384px", minHeight: "380px" }}>
      <SelectField
        ref={selectRef}
        label="Customer"
        placeholder="Search customers..."
        initialLoad="open"
        loadOptions={async (searchValue) =>
          searchValue
            ? customers.filter((c) =>
                c.label.toLowerCase().includes(searchValue.toLowerCase()),
              )
            : customers
        }
        value={selected}
        onSelectedOptionChange={(opt) => setSelected(opt as Customer | null)}
        addItemLabel={(searchText) =>
          searchText
            ? `Add "${searchText}" as new customer`
            : "Add new customer"
        }
        onAddNewItem={openCustomerDialog}
      />

      <Dialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
        <Dialog.Header>Add new customer</Dialog.Header>
        <Dialog.Content>
          <Flex direction="column" gap="3">
            <TextField
              label="Name"
              required
              value={draftName}
              onChange={(e) => setDraftName(e.target.value)}
            />
            <TextField
              label="Phone"
              placeholder="(555) 555-5555"
              value={draftPhone}
              onChange={(e) => setDraftPhone(e.target.value)}
            />
          </Flex>
        </Dialog.Content>
        <Dialog.Footer>
          <Dialog.CancelButton appearance="ghost">Cancel</Dialog.CancelButton>
          <Button
            appearance="primary"
            onClick={saveCustomer}
            disabled={!draftName.trim()}
          >
            Save customer
          </Button>
        </Dialog.Footer>
      </Dialog>
    </div>
  );
}

export default App;
```

> [!NOTE]
>
> Call `invalidate()` on the imperative handle after appending the new option.
> This clears the cached search results and forces a refetch the next time the
> menu opens, so the new item appears immediately. `clearCache()` alone wipes
> the cache but keeps the previously displayed options on screen until
> something else triggers a reload.

## Disabling Search

Pass `disableSearch` to replace the searchable text input with a non-editable select trigger. The field uses the listbox ARIA pattern instead of combobox, providing a simpler interaction when typing to filter adds no value.

This is useful when the option list is short and well-known or you are integrating with an API that does not support search.

```tsx theme={null}
import { useState } from "react";
import { SelectFieldSync } 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 (
    <SelectFieldSync
      disableSearch
      label="Size"
      placeholder="Select a size"
      options={options}
      value={selectedOption}
      onSelectedOptionChange={setSelectedOption}
    />
  );
};
```

## Testing

The field's interactive sub-elements carry a stable `data-anv-part` attribute so end-to-end tests can target them directly. The library owns the part names; you supply the instance identifier.

Attach your own `data-*` attribute (such as `data-cy` or `data-testid`) to the field. It is forwarded to the field's wrapper element, letting you scope queries to a single instance:

```tsx theme={null}
<SelectField data-cy="technician-picker" label="Technician" {...rest} />
```

```ts theme={null}
// Target the input within this specific field
cy.get('[data-cy="technician-picker"] [data-anv-part="input"]').type("Jane");
```

| `data-anv-part` | Element |
| - | - |
| `input` | Text input (searchable mode) |
| `trigger` | Trigger element (`disableSearch` mode) |
| `clear-button` | Button that clears the selection |
| `toggle-button` | Button that opens and closes the menu |
| `menu` | Option list container |
| `option` | An individual option row |
| `dialog` | Menu container in dialog display mode |
| `popover` | Menu container in popover display mode |

> [!NOTE]
>
> The menu renders in the browser's top layer (`menu`, `option`, `dialog`, and `popover` parts), outside the field's wrapper. Descendant queries scoped to your wrapper attribute will not match these parts. Target them by their part attribute directly, or scope to the menu's id — `#{id}-popover` or `#{id}-dialog`, where `{id}` is the field's `id` prop. Only one menu is open at a time.

**SelectField Props**

## `SelectField` Props

```tsx theme={null}
import { useState } from "react";
import { SelectField } from "@servicetitan/anvil2";

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

  return (
    <SelectField
      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,
        }));
      }}
      onSelectedOptionChange={setSelectedOption}
      value={selectedOption}
    />
  );
};
```

**label** `string` *required*

The label of the select field. Supports inline markdown formatting.

**labelAiMark** `boolean | AiMarkWithTooltipOrPopoverConfig`

Displays an AI mark next to the label. Use `true` for the icon only, or pass tooltip or popover configuration. See [AI Marks](https://anvil.servicetitan.com/docs/utilities/web/ai-marks.md).

**loadOptions** `function` *required*

Function to load options. The signature depends on the `lazy` mode:

* **Non-lazy**: `(searchValue: string) => SelectFieldOption[] | Promise<SelectFieldOption[]>`
* **Page-based**: `(searchValue: string, pageNumber: number, pageSize: number) => { options: SelectFieldOption[], hasMore?: boolean }`
* **Offset-based**: `(searchValue: string, offset: number, limit: number) => { options: SelectFieldOption[], hasMore?: boolean }`
* **Group-based**: `(searchValue: string, previousGroupKey: string | number | null) => { options: SelectFieldGroupedOption[], hasMore?: boolean }`

**onSelectedOptionChange** `(option: SelectFieldOption | null) =\> void` *required*

Callback fired when the selected option changes.

**value** `SelectFieldOption | null` *required*

The currently selected option. Must be controlled state.

**addItemLabel** `ReactNode | (searchText: string) =\> ReactNode`

Label rendered inside the "Add new item" button. May be a static node or a callback that receives the current search text and returns a dynamic label (for example, `Add "${searchText}"`). When the callback returns `null` or `undefined`, the button is not rendered — use this to hide the affordance until the search text is valid (for example, a well-formed email). Defaults to `"Add new item"` when omitted but `onAddNewItem` is provided.

**cache** `SelectFieldCacheOptions`

Configuration for caching `loadOptions` results:

* `enabled` — Whether caching is enabled (default: `true`)
* `maxSize` — Maximum number of search values to cache before clearing (default: `15`)

**className** `string`

Custom CSS class name for the wrapper element.

**debounceMs** `number` (default: 200)

Milliseconds to debounce search input before calling `loadOptions`.

**description** `ReactElement | string`

Description text displayed below the input field.

**disableClearButton** `boolean` (default: false)

Whether to hide the clear button.

**disableSearch** `boolean` (default: false)

Replaces the searchable text input with a non-editable select trigger. Uses the listbox ARIA pattern instead of combobox. When combined with `displayMenuAs="dialog"`, the search input in the dialog header is also removed.

**disabled** `boolean` (default: false)

Whether the field is disabled. When disabled, the field cannot be interacted with and the dropdown cannot open.

**displayMenuAs** `'auto' | 'popover' | 'dialog'` (default: auto)

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](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-live).

**groupSorter** `(a: SelectFieldGroupByValue, b: SelectFieldGroupByValue) =\> 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 `SelectFieldSync` or non-lazy `SelectField`. Avoid using with `lazy="group"` as it causes the menu to shift unexpectedly when new groups load.

**groupToString** `(groupValue: SelectFieldGroupByValue) =\> string`

Function to convert group values to display labels. Only used with grouped options. `SelectFieldGroupByValue` is `string | number`.

**hideLabel** `boolean` (default: false)

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** `ReactElement | string`

Hint text displayed below the input field.

**id** `string`

The id of the 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

**isHighlighted** `boolean` (default: false)

When `true`, applies a subdued primary background to draw attention to the field.

**lazy** `'page' | 'offset' | 'group' | false` (default: false)

Enables lazy loading with the specified pagination strategy.

**lazyOptions** `object`

Configuration for lazy loading:

* **Page mode**: `{ pageSize?: number }` (default pageSize: 20)
* **Offset mode**: `{ limit?: number }` (default limit: 20)
* **Group mode**: `{}`

**moreInfo** `ReactNode`

Additional information displayed in a tooltip next to the label. Renders an info icon button that shows the content on hover or focus.

**onAddNewItem** `(searchText: string) =\> void`

Click handler for the "Add new item" button. The button is only rendered when this prop is provided. Receives the current search text. The menu closes synchronously on click and the handler runs immediately after — typically opening a Dialog or Drawer to collect the new item's details. Consumers own overlay presentation, focus return, option list updates, and selection state. See the [Adding New Items](#adding-new-items) section for a full example.

**pinned** `SelectFieldPinnedOptions`

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 (`SelectFieldOption[]`) or dynamic loader function (`(searchValue: string) => SelectFieldOption[] | Promise<SelectFieldOption[]>`)
* `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** `string`

Placeholder text for the input field.

**prefix** `string | ReactElement`

Content to display before the input field.

**readOnly** `boolean` (default: false)

Whether the field is read-only. When read-only, the dropdown can open to view options but selections cannot be changed.

**required** `boolean` (default: false)

Whether the field is required. Shows a red asterisk (\*) next to the label.

**size** `'small' | 'medium' | 'large'`

The size of the select field.

**style** `CSSProperties`

Custom inline styles for the wrapper element.

**suffix** `string | ReactElement`

Content to display after the input field.

**virtualize** `boolean` (default: false)

Enables windowed rendering for the dropdown list. Improves performance for large option sets by only rendering visible items. Consider enabling when the dropdown feels sluggish to open or keyboard navigation becomes laggy, typically around 200–500+ items.

**SelectFieldSync Props**

## `SelectFieldSync` Props

`SelectFieldSync` accepts every prop from `SelectField` except `loadOptions`, `lazy`, and `debounceMs`, plus the additional props listed below. This includes all shared configuration props — for example, `disableSearch`, `disableClearButton`, `displayMenuAs`, `size`, `required`, `readOnly`, `disabled`, `error`, `hint`, `description`, `prefix`, `suffix`, `hideLabel`, `virtualize`, `pinned`, `groupToString`, `groupSorter`, `onAddNewItem`, and `addItemLabel` — all of which pass through validly. The `SelectFieldSync`-specific props (such as `options` and `filter`) are documented below alongside the inherited props for reference.

```tsx theme={null}
import { useState } from "react";
import { SelectFieldSync } 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 (
    <SelectFieldSync
      label="Select an option"
      onSelectedOptionChange={setSelectedOption}
      options={options}
      value={selectedOption}
    />
  );
};
```

**label** `string` *required*

The label of the select field. Supports inline markdown formatting.

**labelAiMark** `boolean | AiMarkWithTooltipOrPopoverConfig`

Displays an AI mark next to the label. Use `true` for the icon only, or pass tooltip or popover configuration. See [AI Marks](https://anvil.servicetitan.com/docs/utilities/web/ai-marks.md).

**onSelectedOptionChange** `(option: SelectFieldOption | null) =\> void` *required*

Callback fired when the selected option changes.

**options** `SelectFieldOption[]` *required*

The array of options to display in the select field.

**value** `SelectFieldOption | null` *required*

The currently selected option. Must be controlled state.

**addItemLabel** `ReactNode | (searchText: string) =\> ReactNode`

Label rendered inside the "Add new item" button. May be a static node or a callback that receives the current search text and returns a dynamic label (for example, `Add "${searchText}"`). When the callback returns `null` or `undefined`, the button is not rendered — use this to hide the affordance until the search text is valid (for example, a well-formed email). Defaults to `"Add new item"` when omitted but `onAddNewItem` is provided.

**className** `string`

Custom CSS class name for the wrapper element.

**description** `ReactElement | string`

Description text displayed below the input field.

**disableClearButton** `boolean` (default: false)

Whether to hide the clear button.

**disableSearch** `boolean` (default: false)

Replaces the searchable text input with a non-editable select trigger. Uses the listbox ARIA pattern instead of combobox. When combined with `displayMenuAs="dialog"`, the search input in the dialog header is also removed.

**disabled** `boolean` (default: false)

Whether the field is disabled. When disabled, the field cannot be interacted with and the dropdown cannot open.

**displayMenuAs** `'auto' | 'popover' | 'dialog'` (default: auto)

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`

Controls how options are filtered and sorted when the user types a search value. Can be:

* A function: `(options: SelectFieldOption[], searchValue: string) => SelectFieldOption[]` — receives all options and returns the filtered subset in the desired display order
* A [match-sorter options object](https://github.com/kentcdodds/match-sorter#options) to customize the default match-sorter behavior (e.g., which keys to match against, ranking strategy)

Before any search is performed, options appear in the order they are supplied. Default: Filters by `label` and `searchText` fields using match-sorter, with results ranked by match quality.

**groupSorter** `(a: SelectFieldGroupByValue, b: SelectFieldGroupByValue) =\> 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: SelectFieldGroupByValue) =\> string`

Function to convert group values to display labels. Only used with grouped options. `SelectFieldGroupByValue` is `string | number`.

**hideLabel** `boolean` (default: false)

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** `ReactElement | string`

Hint text displayed below the input field.

**id** `string`

The id of the select field.

**moreInfo** `ReactNode`

Additional information displayed in a tooltip next to the label. Renders an info icon button that shows the content on hover or focus.

**onAddNewItem** `(searchText: string) =\> void`

Click handler for the "Add new item" button. The button is only rendered when this prop is provided. Receives the current search text. The menu closes synchronously on click and the handler runs immediately after — typically opening a Dialog or Drawer to collect the new item's details. Consumers own overlay presentation, focus return, option list updates, and selection state. See the [Adding New Items](#adding-new-items) section for a full example.

**pinned** `SelectFieldPinnedOptions`

Options to pin to the top of the list. Accepts the same section object format as `SelectField`'s `pinned` prop.

**placeholder** `string`

Placeholder text for the input field.

**prefix** `string | ReactElement`

Content to display before the input field.

**readOnly** `boolean` (default: false)

Whether the field is read-only. When read-only, the dropdown can open to view options but selections cannot be changed.

**required** `boolean` (default: false)

Whether the field is required. Shows a red asterisk (\*) next to the label.

**size** `'small' | 'medium' | 'large'`

The size of the select field.

**style** `CSSProperties`

Custom inline styles for the wrapper element.

**suffix** `string | ReactElement`

Content to display after the input field.

**virtualize** `boolean` (default: false)

Enables windowed rendering for the dropdown list. Improves performance for large option sets by only rendering visible items. Consider enabling when the dropdown feels sluggish to open or keyboard navigation becomes laggy, typically around 200–500+ items.

**SelectFieldOption Type**

## `SelectFieldOption` Type

Options passed to or returned from `SelectField` components must conform to this shape:

```tsx theme={null}
type SelectFieldOption = {
  /** Unique identifier for the option */
  id: string | number;

  /** Display text for the option */
  label: string;

  /** Optional text used for search matching (in addition to label) */
  searchText?: string;

  /** Group key for grouped options (required when using lazy="group") */
  group?: string | number;

  /** Whether the option is disabled and cannot be selected */
  disabled?: boolean;

  /** Optional extra data to associate with the option */
  extra?: Record<string, unknown>;

  /** Optional rich content configuration */
  content?: {
    title?: string;
    description?: string;
    chips?: Pick<ChipProps, "label" | "color" | "textWrap">[];
    avatar?: Pick<AvatarProps, "name" | "status" | "color" | "image">;
    icon?: Pick<IconProps, "svg" | "color">;
  };
};
```

### Content Properties

The `content` object supports the following optional properties:

* `title` — Replaces the `label` as the primary display text in the dropdown.
* `description` — Secondary text displayed below the title.
* `chips` — Array of chip configurations rendered below the title and description. Each chip accepts `label`, `color`, and `textWrap`.
* `avatar` — Renders an avatar to the left of the option text. Accepts `name`, `status`, `color`, and `image`.
* `icon` — Renders an icon to the right of the option text. Accepts `svg` and `color`.

### Example with Rich Content

```tsx theme={null}
const options: SelectFieldOption[] = [
  {
    id: 1,
    label: "John Doe",
    searchText: "john.doe@example.com",
    content: {
      title: "John Doe",
      description: "Engineering",
    },
  },
  {
    id: 2,
    label: "Jane Smith",
    searchText: "jane.smith@example.com",
    content: {
      title: "Jane Smith",
      description: "Design",
    },
  },
];
```

### Example with Avatar, Icon, and Chips

```tsx theme={null}
import GroupIcon from "@servicetitan/anvil2/assets/icons/material/round/groups.svg";

const options: SelectFieldOption[] = [
  {
    id: 1,
    label: "Anvil Team",
    content: {
      title: "Anvil Team (4)",
      description: "Design system maintainer",
      icon: { svg: GroupIcon },
    },
  },
  {
    id: 2,
    label: "Ergonomic Chair",
    content: {
      title: "Ergonomic Chair",
      description: "Offers support and breathability",
      avatar: { name: "Ergonomic Chair", image: "/images/chair.png" },
      chips: [
        { label: "In Stock" },
        { label: "Popular", color: "#F97316" },
      ],
    },
  },
];
```

### Example with Disabled Options

```tsx theme={null}
const options: SelectFieldOption[] = [
  {
    id: 1,
    label: "Available Option",
  },
  {
    id: 2,
    label: "Unavailable Option",
    disabled: true,
  },
  {
    id: 3,
    label: "Another Available Option",
  },
];
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.