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

# Filter Bar - Web

> Standalone filter bar component for filtering content

> [!NOTE]
>
> For development, import `FilterBar` from
> `@servicetitan/anvil2/beta`. For design, find it in the
> [Anvil2 file](https://www.figma.com/design/xaNQsvRKczoxu7pj0M6QT8/Anvil2-v3).

## Live Demo

[toolbar-filter-how-instant](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/toolbar-filter-how-instant?embed=1\&hideNavigation=1\&hideExplorer=1\&terminalHeight=0\&file=src/App.tsx\&view=preview)

## Anatomy

The **Filter Bar** consists of several elements that work together to provide filtering capabilities.

![Image of the anatomy of the toolbar filters](https://mintcdn.com/servicetitan/V_tEHraz1_PFtRiN/images/docs/web/components/toolbar-filter/toolbar_filter_anatomy.png?fit=max\&auto=format\&n=V_tEHraz1_PFtRiN\&q=85\&s=a2ae2b1dd4162be0d80f0c80366e9d46)

1. **Filter Button** - Interactive button that opens a popover or drawer containing filter options
2. **Selected Filter** - Visual indicator showing when a filter has an active selection
3. **Selected Item (for multi select)** - Displays the count of selected items in a multi-select filter, using a chip
4. **Label** - Text identifier that describes what the filter controls
5. **Selected Item (for single select)** - Displays the currently selected option for single-select filters
6. **All Filters** - Button that opens the filter drawer for batch editing of all filters. Appears whenever at least one filter is unreachable from the toolbar.
7. **Search Field (Optional)** - Input field for filtering available options within single-select and multi-select filters
8. **Popover of filter items** - Dropdown panel that appears when a filter button is clicked, containing the filter's selection interface
9. **Apply and cancel filter (Optional)** - Action buttons that appear at the bottom of filter popovers when controlled filtering is enabled

## Options

The **Filter Bar** supports the following configurations to accommodate various filtering scenarios and user interaction patterns.

### Types of Filters

#### Boolean

toolbar-filter-option-boolean

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

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

function App() {
  const [filters, setFilters] = useState([
    {
      type: "boolean",
      id: "booleanFilterId",
      label: "Boolean Filter",
      checked: false,
    },
    {
      type: "boolean",
      id: "booleanFilterId2",
      label: "Boolean Filter 2",
      checked: true,
    },
  ]);

  return (
    <Toolbar associatedContent="name">
      {filters.map((filter) => (
        <Toolbar.ButtonToggle
          key={filter.id}
          checked={filter.checked}
          onClick={() => {
            setFilters((prev) =>
              prev.map((f) =>
                f.id === filter.id ? { ...f, checked: !f.checked } : f,
              ),
            );
            console.log("Filter toggled:", filter.id);
          }}
        >
          {filter.label}
        </Toolbar.ButtonToggle>
      ))}
    </Toolbar>
  );
}

export default App;
```

Boolean filters provide a simple toggle mechanism for on/off states. Users click the filter button to activate or deactivate the filter, with the button's visual state indicating the current selection.

##### Content Guidelines

* The boolean label stays the same across states. Think of this like the label of a Checkbox, which does not change when it is checked or unchecked.

#### Single Select

toolbar-filter-option-single-select

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

```tsx lines theme={null}
import { Flex } from "@servicetitan/anvil2";
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState, useEffect } from "react";

type SelectItem = { id: string; label: string };

function App() {
  const categoryOptions: SelectItem[] = [
    { id: "option1", label: "Option 1" },
    { id: "option2", label: "Option 2" },
    { id: "option3", label: "Option 3" },
  ];

  const statusOptions: SelectItem[] = [
    { id: "option1", label: "Option 1" },
    { id: "option2", label: "Option 2" },
    { id: "option3", label: "Option 3" },
  ];

  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "categoryFilter",
      type: "singleSelect",
      label: "Label",
      options: categoryOptions,
      selectedOption: { id: "option2", label: "Option 2" },
    },
    {
      id: "statusFilter",
      type: "singleSelect",
      label: "Label",
      options: statusOptions,
    },
  ]);

  useEffect(() => {
    // Small delay to ensure the button is rendered
    const timer = setTimeout(() => {
      const button = document.querySelector(
        `button[data-id="categoryFilter"]`,
      ) as HTMLButtonElement;
      if (button) {
        button.click();
      }
    }, 100);

    return () => clearTimeout(timer);
  }, []);

  return (
    <Flex
      style={{ minWidth: "800px", height: "200px" }}
      alignItems="flex-start"
    >
      <FilterBar
        associatedContent="example"
        filters={filters}
        onFilterChange={setFilters}
        flexGrow={1}
      />
    </Flex>
  );
}

export default App;
```

Single select filters display a dropdown list of options where users can choose one item. The selected option appears in the filter button label, and clicking the button opens a popover with the full list of available options.

##### Content Guidelines

* Single selection contains a label, followed by a : , a space, then the selected item. A Chip does not appear in any single selection scenario.
* Example "Group by", "Group by: Job"

#### Multi Select

toolbar-filter-option-multi-select

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

```tsx lines theme={null}
import { Flex } from "@servicetitan/anvil2";
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState, useEffect } from "react";

type SelectItem = { id: string; label: string };

function App() {
  const categoryOptions: SelectItem[] = [
    { id: "option1", label: "Option 1" },
    { id: "option2", label: "Option 2" },
    { id: "option3", label: "Option 3" },
    { id: "option4", label: "Option 4" },
    { id: "option5", label: "Option 5" },
  ];

  const statusOptions: SelectItem[] = [
    { id: "option1", label: "Option 1" },
    { id: "option2", label: "Option 2" },
    { id: "option3", label: "Option 3" },
    { id: "option4", label: "Option 4" },
    { id: "option5", label: "Option 5" },
  ];

  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "categoryFilter",
      type: "multiSelect",
      label: "Label",
      options: categoryOptions,
      selectedOptions: [
        { id: "option2", label: "Option 2" },
        { id: "option4", label: "Option 4" },
      ],
    },
    {
      id: "statusFilter",
      type: "multiSelect",
      label: "Label",
      options: statusOptions,
      selectedOptions: [],
    },
  ]);

  useEffect(() => {
    // Small delay to ensure the button is rendered
    const timer = setTimeout(() => {
      const button = document.querySelector(
        `button[data-id="categoryFilter"]`,
      ) as HTMLButtonElement;
      if (button) {
        button.click();
      }
    }, 100);

    return () => clearTimeout(timer);
  }, []);

  return (
    <Flex
      style={{ minWidth: "800px", height: "300px" }}
      alignItems="flex-start"
    >
      <FilterBar
        associatedContent="example"
        filters={filters}
        onFilterChange={setFilters}
        flexGrow={1}
      />
    </Flex>
  );
}

export default App;
```

Multi select filters allow users to select multiple options from a list. The filter button displays a chip with the count of selected items when selections are active. Clicking the button opens a popover where users can select or deselect multiple options.

##### Content Guidelines

* Multi selection contains a label, followed by a Chip. Multi selection never shows the selected items in the Toolbar itself.
* Example "Group by", "Group by (1)", "Group by (2)"

#### Date

toolbar-filter-option-single-date

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

```tsx lines theme={null}
import { Flex } from "@servicetitan/anvil2";
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState, useEffect } from "react";

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "categoryFilter",
      type: "date",
      label: "Label",
      mode: "mm/dd/yyyy",
      value: "2026-01-01",
    },
    {
      id: "statusFilter",
      type: "date",
      label: "Label",
      mode: "mm/dd/yyyy",
    },
  ]);

  useEffect(() => {
    // Small delay to ensure the button is rendered
    const timer = setTimeout(() => {
      const button = document.querySelector(
        `button[data-id="categoryFilter"]`,
      ) as HTMLButtonElement;
      if (button) {
        button.click();
      }
    }, 100);

    return () => clearTimeout(timer);
  }, []);

  return (
    <Flex
      style={{ minWidth: "800px", height: "500px" }}
      alignItems="flex-start"
    >
      <FilterBar
        associatedContent="example"
        filters={filters}
        onFilterChange={setFilters}
        flexGrow={1}
      />
    </Flex>
  );
}

export default App;
```

Date filters provide a calendar interface for selecting a single date. The filter button displays the selected date in the configured format, and clicking it opens a popover with a calendar picker. Set minimum, maximum, or unavailable dates to disable out-of-range days in the calendar. Users can still type a date outside those bounds, in which case the field shows an error message.

##### Date Range

toolbar-filter-option-multi-date

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

```tsx lines theme={null}
import { Flex } from "@servicetitan/anvil2";
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState, useEffect } from "react";

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "categoryFilter",
      type: "dateRange",
      label: "Label",
      mode: "mm/dd/yyyy",
      value: {
        startDate: "2026-01-01",
        endDate: "2026-01-14",
      },
    },
    {
      id: "statusFilter",
      type: "dateRange",
      label: "Label",
      mode: "mm/dd/yyyy",
    },
  ]);

  useEffect(() => {
    // Small delay to ensure the button is rendered
    const timer = setTimeout(() => {
      const button = document.querySelector(
        `button[data-id="categoryFilter"]`,
      ) as HTMLButtonElement;
      if (button) {
        button.click();
      }
    }, 100);

    return () => clearTimeout(timer);
  }, []);

  return (
    <Flex
      style={{ minWidth: "800px", height: "500px" }}
      alignItems="flex-start"
    >
      <FilterBar
        associatedContent="example"
        filters={filters}
        onFilterChange={setFilters}
        flexGrow={1}
      />
    </Flex>
  );
}

export default App;
```

Date range filters enable users to select a start date and an end date. The filter button displays both dates when selected, and clicking it opens a popover with dual calendar pickers for selecting the range. Set minimum, maximum, or unavailable dates to disable out-of-range days in the calendars. Users can still type dates outside those bounds, in which case the field shows an error message.

##### Date List

toolbar-filter-option-date-list

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

```tsx lines theme={null}
import { Flex } from "@servicetitan/anvil2";
import {
  FilterBar,
  type Filter,
  type DateListOption,
} from "@servicetitan/anvil2/beta";
import { DateTime } from "luxon";
import { useState, useEffect } from "react";

function App() {
  const today = DateTime.local().startOf("day");
  const dateListOptions: DateListOption[] = [
    { id: "today", label: "Today", value: today.toISODate() },
    {
      id: "yesterday",
      label: "Yesterday",
      value: today.minus({ days: 1 }).toISODate(),
    },
    {
      id: "last7Days",
      label: "Last 7 days",
      value: {
        startDate: today.minus({ days: 6 }).toISODate() ?? "",
        endDate: today.toISODate() ?? "",
      },
    },
  ];

  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "dueDateFilter",
      type: "dateList",
      label: "Due date",
      mode: "mm/dd/yyyy",
      options: dateListOptions,
    },
  ]);

  useEffect(() => {
    // Small delay to ensure the button is rendered
    const timer = setTimeout(() => {
      const button = document.querySelector(
        `button[data-id="dueDateFilter"]`,
      ) as HTMLButtonElement;
      if (button) {
        button.click();
      }
    }, 100);

    return () => clearTimeout(timer);
  }, []);

  return (
    <Flex
      style={{ minWidth: "800px", height: "500px" }}
      alignItems="flex-start"
    >
      <FilterBar
        associatedContent="example"
        filters={filters}
        onFilterChange={setFilters}
        flexGrow={1}
      />
    </Flex>
  );
}

export default App;
```

A Date List filter renders a single-select list of date "buckets" (e.g.
"Today", "Last 7 days") followed by four menu options (`On…`, `Before…`,
`After…`, `Custom Range…`). Consumer-supplied options resolve to a date or
range and commit immediately on click; menu options open a Dialog with the
appropriate date picker. The popover never shows Apply/Cancel since picking
an option is itself the confirming action.

`On…` matches the selected day. `Before…` matches days before it, and
`After…` matches days after it.

##### Content Guidelines

* The Calendar icon and label are always present.
* For the label, use "Date" if there is no specific name that would be more descriptive.
* Single selection examples:
* "Date", "Date: Jan 3, 2025"
* "Last Modifed On", "Last Modified On: Nov 1, 2026"
* Date range examples:
* "Date", "Date: Jan 3 – Jan 10, 2025"
* "Date", "Date: Dec 31, 2025 – Jan 1, 2026"
* Date list examples:
* "Due date" (no selection)
* "Due date: Today (Jan 28, 2026)" (consumer single-date option)
* "Due date: Last 7 days (Jan 28 – Feb 4, 2026)" (consumer range option)
* "Due date: On (Sep 12, 2026)" (menu `On…`, `Before…`, `After…` after dialog)
* "Due date: Jan 28 – Feb 4, 2026" (menu `Custom Range…` after dialog — option name omitted since the range is self-explanatory)

###### Date Format guidelines

* Month as 3 letter abbreviation
* Date without a leading zero
* Comma separation
* Full numeric year, omitted if a date range for the first value and both dates are in the same year.
* An en dash (–) between dates

###### Why does the format not use MM/DD/YYYY?

Accessibility standards require explanation of the format on the page when using ambiguous formats like 01/05/2026. Filter Button avoids this requirement by spelling out the month part. Users do not type directly into Filter Button, so the format remains clear and accessible.

###### Why does the format not spell out the whole month?

Filter Bar prioritizes horizontal space efficiency over verbosity. In context, the date icon further emphasizes a user is reading a month.

#### Custom

toolbar-filter-option-custom

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

```tsx lines theme={null}
import {
  FilterBar,
  type Filter,
  type CustomFilter,
} from "@servicetitan/anvil2/beta";
import { Radio, Combobox, ComboboxSelect, Flex } from "@servicetitan/anvil2";
import { useState, useEffect } from "react";

type ComboboxItem = {
  id: string;
  label: string;
};

const comboboxItems: ComboboxItem[] = [
  { id: "is", label: "Is" },
  { id: "isNot", label: "Is Not" },
];

type CustomFilterValue = {
  id: number;
  label: string;
};

const filterOptions = [
  { id: 1, label: "Option 1" },
  { id: 2, label: "Option 2" },
  { id: 3, label: "Option 3" },
];

function OperatorCombobox() {
  const [selectedItem, setSelectedItem] = useState<ComboboxItem | null>(
    comboboxItems[0],
  );

  return (
    <ComboboxSelect
      disableClearSelection
      items={comboboxItems}
      itemToString={(item) => (item ? item.label : "")}
      itemToKey={(item) => (item ? item.id : null)}
      selectedItem={selectedItem}
      onChange={setSelectedItem}
    >
      <Combobox.SelectTrigger label="Custom Filter" />
      <Combobox.Content>
        {({ items }) => (
          <Combobox.List>
            {items.map((item, i) => (
              <Combobox.Item key={item.id} item={item} index={i}>
                {item.label}
              </Combobox.Item>
            ))}
          </Combobox.List>
        )}
      </Combobox.Content>
    </ComboboxSelect>
  );
}

function App() {
  const customFilter: CustomFilter<CustomFilterValue> = {
    id: "customFilterId",
    type: "custom",
    label: "Custom Filter",
    buttonRender: ({ value, onChange }) => (
      <Flex direction="column" gap={3} style={{ padding: "0.5rem" }}>
        <OperatorCombobox />
        <Radio.Group legend="Custom filter button">
          {filterOptions.map((option) => (
            <Radio
              key={option.id}
              name="custom-filter-radio-popover"
              value={option.id.toString()}
              label={option.label}
              checked={value?.id === option.id}
              onChange={(e) => {
                if (!e?.target) return;
                const selected = filterOptions.find(
                  (opt) => opt.id.toString() === e.target.value,
                );
                onChange(selected);
              }}
            />
          ))}
        </Radio.Group>
      </Flex>
    ),
    drawerRender: ({ value, onChange }) => (
      <Flex direction="column" gap={2}>
        <OperatorCombobox />
        <Radio.Group legend="Custom filter drawer">
          {filterOptions.map((option) => (
            <Radio
              key={option.id}
              name="custom-filter-radio-drawer"
              value={option.id.toString()}
              label={option.label}
              checked={value?.id === option.id}
              onChange={(e) => {
                if (!e?.target) return;
                const selected = filterOptions.find(
                  (opt) => opt.id.toString() === e.target.value,
                );
                onChange(selected);
              }}
            />
          ))}
        </Radio.Group>
      </Flex>
    ),
  };

  const [filters, setFilters] = useState<Filter[]>([customFilter]);

  // Update label based on current filter value
  useEffect(() => {
    const currentFilter = filters.find((f) => f.id === "customFilterId") as
      | CustomFilter<CustomFilterValue>
      | undefined;
    if (currentFilter) {
      const currentValue = currentFilter.value;
      const newLabel = currentValue
        ? `Custom Filter: ${currentValue.label}`
        : "Custom Filter";
      if (currentFilter.label !== newLabel) {
        setFilters((prev) =>
          prev.map((f) =>
            f.id === "customFilterId" ? { ...f, label: newLabel } : f,
          ),
        );
      }
    }
  }, [filters]);

  useEffect(() => {
    // Small delay to ensure the button is rendered
    const timer = setTimeout(() => {
      const button = document.querySelector(
        `button[data-id="customFilterId"]`,
      ) as HTMLButtonElement;
      if (button) {
        button.click();
      }
    }, 100);

    return () => clearTimeout(timer);
  }, []);

  return (
    <Flex
      style={{ minWidth: "800px", height: "290px" }}
      alignItems="flex-start"
    >
      <FilterBar
        associatedContent="example"
        filters={filters}
        onFilterChange={(updatedFilters) => {
          setFilters(updatedFilters);
        }}
        flexGrow={1}
      />
    </Flex>
  );
}

export default App;
```

Custom filters provide complete flexibility for implementing specialized filtering interfaces. Developers define custom render functions for both the popover and drawer views, allowing any type of filter control to be integrated into the toolbar.

##### Content Guidelines

* Custom filters must use a label. It may either use selection text or a Chip, depending on the intent of the custom filter.

### Apply and Cancel

toolbar-filter-option-apply

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

```tsx lines theme={null}
import { Flex } from "@servicetitan/anvil2";
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState, useEffect } from "react";

type SelectItem = { id: string; label: string };

function App() {
  const categoryOptions: SelectItem[] = [
    { id: "option1", label: "Option 1" },
    { id: "option2", label: "Option 2" },
    { id: "option3", label: "Option 3" },
  ];

  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "categoryFilter",
      type: "singleSelect",
      label: "Label",
      options: categoryOptions,
    },
  ]);

  useEffect(() => {
    // Small delay to ensure the button is rendered
    const timer = setTimeout(() => {
      const button = document.querySelector(
        `button[data-id="categoryFilter"]`,
      ) as HTMLButtonElement;
      if (button) {
        button.click();
      }
    }, 100);

    return () => clearTimeout(timer);
  }, []);

  return (
    <Flex
      style={{ minWidth: "800px", height: "245px" }}
      alignItems="flex-start"
    >
      <FilterBar
        associatedContent="example"
        filters={filters}
        onFilterChange={setFilters}
        controlledFiltering={true}
        flexGrow={1}
      />
    </Flex>
  );
}

export default App;
```

By default, filter selections apply immediately when a user makes a choice. When controlled filtering is enabled, Apply and Cancel buttons appear at the bottom of filter popovers. Users can make multiple changes in draft state, then click Apply to submit all changes at once or Cancel to discard them. The filter drawer always operates in controlled mode with Apply and Cancel buttons in the footer. See [Filter Fetching](#filter-fetching) for more information on when to use Apply and Cancel.

### Search

toolbar-filter-option-search

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

```tsx lines theme={null}
import { Flex } from "@servicetitan/anvil2";
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState, useEffect } from "react";

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "categoryFilter",
      type: "singleSelect",
      label: "Label",
      // SelectMenuSync handles search client-side via match-sorter automatically.
      options: [
        { id: "option1", label: "Option 1" },
        { id: "option2", label: "Option 2" },
        { id: "option3", label: "Option 3" },
        { id: "option4", label: "Option 4" },
        { id: "option5", label: "Option 5" },
      ],
    },
  ]);

  useEffect(() => {
    // Small delay to ensure the button is rendered
    const timer = setTimeout(() => {
      const button = document.querySelector(
        `button[data-id="categoryFilter"]`,
      ) as HTMLButtonElement;
      if (button) {
        button.click();
      }
    }, 100);

    return () => clearTimeout(timer);
  }, []);

  return (
    <Flex
      style={{ minWidth: "800px", height: "320px" }}
      alignItems="flex-start"
    >
      <FilterBar
        associatedContent="example"
        filters={filters}
        onFilterChange={setFilters}
        flexGrow={1}
      />
    </Flex>
  );
}

export default App;
```

Single select and multi select filters can include an optional search field at the top of their popovers. The search field allows users to filter the available options by typing, making it easier to find specific items in large lists. The implementing team controls how the search filters the items through callback functions.

## Behavior

The **Filter Bar** responds to user interaction and space constraints with distinct behaviors for filter management and layout adaptation.

### All Filters

toolbar-filter-behavior-drawer-full-page

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/toolbar-filter-behavior-drawer-full-page?file=src/App.tsx)

```tsx lines theme={null}
import {
  FilterBar,
  type Filter,
  type CustomFilter,
} from "@servicetitan/anvil2/beta";
import { Radio, Combobox, ComboboxSelect, Flex } from "@servicetitan/anvil2";
import { useState, useEffect } from "react";

type ComboboxItem = {
  id: string;
  label: string;
};

const comboboxItems: ComboboxItem[] = [
  { id: "is", label: "Is" },
  { id: "isNot", label: "Is Not" },
];

type CustomFilterValue = {
  id: number;
  label: string;
};

const filterOptions = [
  { id: 1, label: "Option 1" },
  { id: 2, label: "Option 2" },
  { id: 3, label: "Option 3" },
];

type SelectItem = { id: string; label: string };

const categoryOptions: SelectItem[] = [
  { id: "option1", label: "Option 1" },
  { id: "option2", label: "Option 2" },
  { id: "option3", label: "Option 3" },
];

const multiSelectCategoryOptions: SelectItem[] = [
  { id: "option1", label: "Option 1" },
  { id: "option2", label: "Option 2" },
  { id: "option3", label: "Option 3" },
  { id: "option4", label: "Option 4" },
  { id: "option5", label: "Option 5" },
];

function OperatorCombobox() {
  const [selectedItem, setSelectedItem] = useState<ComboboxItem | null>(
    comboboxItems[0],
  );

  return (
    <ComboboxSelect
      disableClearSelection
      items={comboboxItems}
      itemToString={(item) => (item ? item.label : "")}
      itemToKey={(item) => (item ? item.id : null)}
      selectedItem={selectedItem}
      onChange={setSelectedItem}
    >
      <Combobox.SelectTrigger label="Custom Filter" />
      <Combobox.Content>
        {({ items }) => (
          <Combobox.List>
            {items.map((item, i) => (
              <Combobox.Item key={item.id} item={item} index={i}>
                {item.label}
              </Combobox.Item>
            ))}
          </Combobox.List>
        )}
      </Combobox.Content>
    </ComboboxSelect>
  );
}

function App() {
  const customFilter: CustomFilter<CustomFilterValue> = {
    id: "customFilterId",
    type: "custom",
    label: "Custom filter",
    buttonRender: ({ value, onChange }) => (
      <Flex direction="column" gap={3} style={{ padding: "0.5rem" }}>
        <OperatorCombobox />
        <Radio.Group legend="Custom filter button">
          {filterOptions.map((option) => (
            <Radio
              key={option.id}
              name="custom-filter-radio-popover"
              value={option.id.toString()}
              label={option.label}
              checked={value?.id === option.id}
              onChange={(e) => {
                if (!e?.target) return;
                const selected = filterOptions.find(
                  (opt) => opt.id.toString() === e.target.value,
                );
                onChange(selected);
              }}
            />
          ))}
        </Radio.Group>
      </Flex>
    ),
    drawerRender: ({ value, onChange }) => (
      <Flex direction="column" gap={2}>
        <OperatorCombobox />
        <Radio.Group legend="Custom filter drawer">
          {filterOptions.map((option) => (
            <Radio
              key={option.id}
              name="custom-filter-radio-drawer"
              value={option.id.toString()}
              label={option.label}
              checked={value?.id === option.id}
              onChange={(e) => {
                if (!e?.target) return;
                const selected = filterOptions.find(
                  (opt) => opt.id.toString() === e.target.value,
                );
                onChange(selected);
              }}
            />
          ))}
        </Radio.Group>
      </Flex>
    ),
  };

  const [filters, setFilters] = useState<Filter[]>([
    // Boolean filter
    {
      type: "boolean",
      id: "booleanFilterId",
      label: "Boolean",
      checked: true,
    },
    // Single select filter
    {
      id: "categoryFilter",
      type: "singleSelect",
      label: "Single select",
      options: categoryOptions,
      selectedOption: { id: "option2", label: "Option 2" },
    },
    // Multi select filter
    {
      id: "categoryMultiSelectFilter",
      type: "multiSelect",
      label: "Multi select",
      options: multiSelectCategoryOptions,
      selectedOptions: [
        { id: "option2", label: "Option 2" },
        { id: "option4", label: "Option 4" },
      ],
    },
    // Date filter
    {
      id: "dateFilter",
      type: "date",
      label: "Single date",
      mode: "mm/dd/yyyy",
      value: "2026-01-01",
    },
    // Date range filter
    {
      id: "dateRangeFilter",
      type: "dateRange",
      label: "Multi date",
      mode: "mm/dd/yyyy",
      value: {
        startDate: "2026-01-01",
        endDate: "2026-01-14",
      },
    },
    // Custom filter
    {
      ...customFilter,
      value: { id: 2, label: "Option 2" },
    },
  ]);

  // Update label based on current filter value for custom filter
  useEffect(() => {
    const currentFilter = filters.find((f) => f.id === "customFilterId") as
      | CustomFilter<CustomFilterValue>
      | undefined;
    if (currentFilter) {
      const currentValue = currentFilter.value;
      const newLabel = currentValue
        ? `Custom filter: ${currentValue.label}`
        : "Custom filter";
      if (currentFilter.label !== newLabel) {
        setFilters((prev) =>
          prev.map((f) =>
            f.id === "customFilterId" ? { ...f, label: newLabel } : f,
          ),
        );
      }
    }
  }, [filters]);

  useEffect(() => {
    // Small delay to ensure the button is rendered
    const timer = setTimeout(() => {
      const button = document.querySelector(
        `button[data-anv="filter-group-drawer-trigger"]`,
      ) as HTMLButtonElement;
      if (button) {
        button.click();
      }
    }, 100);

    return () => clearTimeout(timer);
  }, []);

  return (
    <Flex style={{ minWidth: "800px", height: "775px" }}>
      <FilterBar
        associatedContent="example"
        filters={filters}
        onFilterChange={setFilters}
        flexGrow={1}
      />
    </Flex>
  );
}

export default App;
```

The **Filter Bar** surfaces an "All Filters" button that opens the filter drawer for managing multiple filters at once. The button only appears when at least one filter is unreachable from the toolbar — that is, when a filter is configured as drawer-only, when an inline filter has overflowed, or when the container is too narrow to show inline filters at all. When every filter fits inline and none are drawer-only, the button does not render. While present, the button shows a chip with the count of active filters that need the drawer to access.

### Drawer of Filters

The filter drawer is a side panel that displays all available filters in a unified interface. It maintains draft state for all filters until the user clicks Apply, enabling batch updates. The drawer includes a Clear All button that resets all filters to their default states. This batch filtering approach allows users to make multiple filter changes before committing them.

### Drawer-only Filters

Setting `drawerOnly: true` on a filter keeps it accessible only through the filter drawer; it never renders inline in the toolbar, even when there is room for it. Use this to surface lower-priority filters or filters whose UI does not fit the toolbar shape without crowding the primary filters. The flag applies to every filter type — boolean, single select, multi select, date, date range, date list, and custom.

A drawer-only custom filter only requires `drawerRender` — `buttonRender` is forbidden, since the filter never appears inline.

### Wrapping

toolbar-filter-behavior-wrap

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

```tsx lines theme={null}
import {
  FilterBar,
  type Filter,
  type CustomFilter,
} from "@servicetitan/anvil2/beta";
import { Radio, Combobox, ComboboxSelect, Flex } from "@servicetitan/anvil2";
import { useState, useEffect } from "react";

type ComboboxItem = {
  id: string;
  label: string;
};

const comboboxItems: ComboboxItem[] = [
  { id: "is", label: "Is" },
  { id: "isNot", label: "Is Not" },
];

type CustomFilterValue = {
  id: number;
  label: string;
};

const filterOptions = [
  { id: 1, label: "Option 1" },
  { id: 2, label: "Option 2" },
  { id: 3, label: "Option 3" },
];

type SelectItem = { id: string; label: string };

const categoryOptions: SelectItem[] = [
  { id: "option1", label: "Option 1" },
  { id: "option2", label: "Option 2" },
  { id: "option3", label: "Option 3" },
];

const multiSelectCategoryOptions: SelectItem[] = [
  { id: "option1", label: "Option 1" },
  { id: "option2", label: "Option 2" },
  { id: "option3", label: "Option 3" },
  { id: "option4", label: "Option 4" },
  { id: "option5", label: "Option 5" },
];

function OperatorCombobox() {
  const [selectedItem, setSelectedItem] = useState<ComboboxItem | null>(
    comboboxItems[0],
  );

  return (
    <ComboboxSelect
      disableClearSelection
      items={comboboxItems}
      itemToString={(item) => (item ? item.label : "")}
      itemToKey={(item) => (item ? item.id : null)}
      selectedItem={selectedItem}
      onChange={setSelectedItem}
    >
      <Combobox.SelectTrigger label="Custom Filter" />
      <Combobox.Content>
        {({ items }) => (
          <Combobox.List>
            {items.map((item, i) => (
              <Combobox.Item key={item.id} item={item} index={i}>
                {item.label}
              </Combobox.Item>
            ))}
          </Combobox.List>
        )}
      </Combobox.Content>
    </ComboboxSelect>
  );
}

function App() {
  const customFilter: CustomFilter<CustomFilterValue> = {
    id: "customFilterId",
    type: "custom",
    label: "Custom filter",
    buttonRender: ({ value, onChange }) => (
      <Flex direction="column" gap={3} style={{ padding: "0.5rem" }}>
        <OperatorCombobox />
        <Radio.Group legend="Custom filter button">
          {filterOptions.map((option) => (
            <Radio
              key={option.id}
              name="custom-filter-radio-popover"
              value={option.id.toString()}
              label={option.label}
              checked={value?.id === option.id}
              onChange={(e) => {
                if (!e?.target) return;
                const selected = filterOptions.find(
                  (opt) => opt.id.toString() === e.target.value,
                );
                onChange(selected);
              }}
            />
          ))}
        </Radio.Group>
      </Flex>
    ),
    drawerRender: ({ value, onChange }) => (
      <Flex direction="column" gap={2}>
        <OperatorCombobox />
        <Radio.Group legend="Custom filter drawer">
          {filterOptions.map((option) => (
            <Radio
              key={option.id}
              name="custom-filter-radio-drawer"
              value={option.id.toString()}
              label={option.label}
              checked={value?.id === option.id}
              onChange={(e) => {
                if (!e?.target) return;
                const selected = filterOptions.find(
                  (opt) => opt.id.toString() === e.target.value,
                );
                onChange(selected);
              }}
            />
          ))}
        </Radio.Group>
      </Flex>
    ),
  };

  const [filters, setFilters] = useState<Filter[]>([
    // Boolean filter
    {
      type: "boolean",
      id: "booleanFilterId",
      label: "Boolean",
      checked: true,
    },
    // Single select filter
    {
      id: "categoryFilter",
      type: "singleSelect",
      label: "Single select",
      options: categoryOptions,
      selectedOption: { id: "option2", label: "Option 2" },
    },
    // Multi select filter
    {
      id: "categoryMultiSelectFilter",
      type: "multiSelect",
      label: "Multi select",
      options: multiSelectCategoryOptions,
      selectedOptions: [
        { id: "option2", label: "Option 2" },
        { id: "option4", label: "Option 4" },
      ],
    },
    // Date filter
    {
      id: "dateFilter",
      type: "date",
      label: "Single date",
      mode: "mm/dd/yyyy",
      value: "2026-01-01",
    },
    // Date range filter
    {
      id: "dateRangeFilter",
      type: "dateRange",
      label: "Multi date",
      mode: "mm/dd/yyyy",
      value: {
        startDate: "2026-01-01",
        endDate: "2026-01-14",
      },
    },
    // Custom filter
    {
      ...customFilter,
      value: { id: 2, label: "Option 2" },
    },
  ]);

  // Update label based on current filter value for custom filter
  useEffect(() => {
    const currentFilter = filters.find((f) => f.id === "customFilterId") as
      | CustomFilter<CustomFilterValue>
      | undefined;
    if (currentFilter) {
      const currentValue = currentFilter.value;
      const newLabel = currentValue
        ? `Custom filter: ${currentValue.label}`
        : "Custom filter";
      if (currentFilter.label !== newLabel) {
        setFilters((prev) =>
          prev.map((f) =>
            f.id === "customFilterId" ? { ...f, label: newLabel } : f,
          ),
        );
      }
    }
  }, [filters]);

  return (
    <div style={{ minWidth: "800px" }}>
      <FilterBar
        associatedContent="example"
        filters={filters}
        onFilterChange={setFilters}
        flexGrow={1}
      />
    </div>
  );
}

export default App;
```

Filters that don't fit on a single line automatically wrap to the next line so every filter remains visible.

### Responsive Behavior

Below a 640px container width, inline filters are hidden and only the "All Filters" drawer trigger button remains visible. The Filter Bar uses container queries to detect available space and switch to drawer-only mode. Set `disableCollapse` to keep filters inline at every container width.

## Keyboard Interaction

Users can navigate the **Filter Bar** using standard keyboard controls.

| Key | Description |
| - | - |
| Tab | Moves focus between filter buttons and the "All Filters" button |
| Enter | Opens the focused filter's popover or activates a boolean filter toggle |
| Escape | Closes an open Popover or Drawer and returns focus to the trigger button |
| Arrow Keys | Navigate through options within filter Popovers and Drawers |
| Space | Activates focused filter controls within popovers and drawers |

## When to use

Use the Filter Bar when needing to apply the filter pattern to content.

## How to use

### Where and how filters are represented

![Image of the anatomy of the toolbar
filters](https://mintcdn.com/servicetitan/ONtkJXRpB2x7ch2s/images/docs/web/components/toolbar-filter/placement.png?fit=max\&auto=format\&n=ONtkJXRpB2x7ch2s\&q=85\&s=877b4a6c6ba9d7aaa467d8b03b0594ff)

**Do**
place filters toward the top of a page's content, below the page header arranged horizontally.

The All Filter acts as a central hub for all the available filters for end users. It always opens into a Drawer. It's always labeled as 'Filters'.

### Grouping Filters

On the page, filters exists as part of a larger Toolbar

* In general, show most-used filters first.
* Rarely used filters do not have to be shown on the page and may be placed within the Drawer layout instead.
* If a filter group is used consistently within a product area, consider consistency in ordering across the area.
* Avoid changing the placement or conditions of actions such as the 'Clear All', 'Filters', and search bar.
* Multiple filter groups may exist if a page contains multiple layers of filterable content and each section needs its own unique filtering.

### Filter Fetching

Filter fetching refers to when filters get applied to an area. There are three types of filter fetching: instant, apply per-filter, and batch.

#### Instant filter

Instant filtering begins the process of filtering results the moment a user makes a selection within a filter. This can only occur in the Toolbar filter view and not in the Drawer. This is the generally preferred option for filtering as it reduces the amount of steps users must take.

[toolbar-filter-how-instant](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/toolbar-filter-how-instant?embed=1\&hideNavigation=1\&hideExplorer=1\&terminalHeight=0\&file=src/App.tsx\&view=preview)

#### Apply per-filter

Apply per-filter sets individual Apply + Cancel actions onto each individual filter. This is a useful method when performance is particularly important.

[toolbar-filter-how-apply-per-filter](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/toolbar-filter-how-apply-per-filter?embed=1\&hideNavigation=1\&hideExplorer=1\&terminalHeight=0\&file=src/App.tsx\&view=preview)

#### Batch filter

Batch filtering waits to apply a filter only after clicking a standalone Apply action. Batch filtering is not made available in the standard Toolbar view, while being the only method used in the Drawer view.

[toolbar-filter-behavior-drawer](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/toolbar-filter-behavior-drawer?embed=1\&hideNavigation=1\&hideExplorer=1\&terminalHeight=0\&file=src/App.tsx\&view=preview)

#### Which fetching type is best for your case?

* Instant filtering is the generally preferred method, as it provides real-time feedback to users.
* Apply per-filter is preferred when performance is an issue, or if a user needs to build out a filter before needing to apply it.
* Instant and apply per-filter fetching should not be mixed. If per-filter is ever needed in one filter, the whole filter set should also use this method.
* Filters in the Drawer always use batch apply. No individual filter in the Drawer should have its own apply action.
* Batch filtering on the page is considered an edge case that requires a custom implementation.

### Wrapping

Wrap filters when a table or other filterable content lives inside a smaller surface than a full page, such as a Drawer or Dialog. Wrapping keeps filters inline so users never need an All Filters button, which would open a second Drawer over the current surface.

**Do**
wrap filters when they appear inside a Drawer, Dialog, or other smaller surface, so every filter stays reachable without opening another Drawer.

**Don’t**
collapse filters into an All Filters button inside a Drawer or Dialog. That opens a nested Drawer over the current surface.

### Content Guidelines

* All Filters need to have a label. This is the equivalent of a label found in traditional form elements.
* See each [individual filter type](#types-of-filters) for specific content guidelines.

## Code

**Implementation**

> [!NOTE]
>
> **Beta Feature**
>
> This 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](https://servicetitan.enterprise.slack.com/archives/CBSRGHTRS) channel with any questions or feedback!

## Common Examples

### Standard Filters

filter-bar-standard

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

```tsx lines expandable theme={null}
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState } from "react";

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "activeFilter",
      type: "boolean",
      label: "Active only",
      checked: false,
    },
    {
      id: "statusFilter",
      type: "singleSelect",
      label: "Status",
      options: [
        { id: "scheduled", label: "Scheduled" },
        { id: "inProgress", label: "In progress" },
        { id: "completed", label: "Completed" },
      ],
    },
    {
      id: "businessUnitFilter",
      type: "multiSelect",
      label: "Business unit",
      options: [
        { id: "plumbing", label: "Plumbing" },
        { id: "hvac", label: "HVAC" },
        { id: "electrical", label: "Electrical" },
      ],
      selectedOptions: [],
    },
    {
      id: "scheduledDateFilter",
      type: "date",
      label: "Scheduled date",
      mode: "mm/dd/yyyy",
    },
  ]);

  // Demo-only wrapper: keeps the preview wide enough to show inline filters.
  return (
    <div style={{ minWidth: "800px" }}>
      <FilterBar
        associatedContent="jobs table"
        filters={filters}
        onFilterChange={setFilters}
      />
    </div>
  );
}

export default App;
```

A filter group is created by passing a `filters` object array to the `FilterBar` component. The component takes the object and builds the set of filters for you according to the design patterns. You can create a custom filter if needed, but there are several prebuilt filter types to pick from:

#### Boolean

filter-bar-boolean

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

```tsx lines expandable theme={null}
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState } from "react";

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "unassignedFilter",
      type: "boolean",
      label: "Unassigned",
      checked: true,
    },
    {
      id: "overdueFilter",
      type: "boolean",
      label: "Overdue",
      checked: false,
    },
  ]);

  // Demo-only wrapper: keeps the preview wide enough to show inline filters.
  return (
    <div style={{ minWidth: "800px" }}>
      <FilterBar
        associatedContent="jobs table"
        filters={filters}
        onFilterChange={setFilters}
      />
    </div>
  );
}

export default App;
```

#### Search

A pinned, always-visible search field. Unlike other filter types, it never renders as a trigger button or popover, and it participates in the same state as every other filter: a non-empty value reveals the "Clear Filters" button, and clearing all filters clears the search text.

`FilterBar` always renders it first among the inline filters, regardless of where it sits in the `filters` array. Keyboard navigation treats it as a fixed edge of the filter sequence, so that position is the only one that matches how arrow keys move.

filter-bar-search

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

```tsx lines expandable theme={null}
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState } from "react";

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "statusFilter",
      type: "singleSelect",
      label: "Status",
      options: [
        { id: "scheduled", label: "Scheduled" },
        { id: "completed", label: "Completed" },
      ],
    },
    // Placed last in the array; FilterBar still renders it first.
    // A non-empty value counts as an active filter, so Clear Filters appears.
    {
      id: "searchFilter",
      type: "search",
      label: "Search",
      placeholder: "Search jobs...",
      value: "Water heater",
    },
  ]);

  // Demo-only wrapper: keeps the preview wide enough to show inline filters.
  return (
    <div style={{ minWidth: "800px" }}>
      <FilterBar
        associatedContent="jobs table"
        filters={filters}
        onFilterChange={setFilters}
      />
    </div>
  );
}

export default App;
```

#### Single Select

The item list for single select is populated and controlled by the implementing team. This includes any filtering that needs to be done when a search field is present.

Single select filters are backed by `SelectMenuSync` in the toolbar and `SelectFieldSync` in the drawer. Pass a static `options` array; client-side filtering happens automatically via match-sorter. To customize filtering, pass a `filter` function or a `MatchSorterOptions` config.

filter-bar-single-select

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

```tsx lines expandable theme={null}
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState } from "react";

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "statusFilter",
      type: "singleSelect",
      label: "Status",
      options: [
        { id: "scheduled", label: "Scheduled" },
        { id: "dispatched", label: "Dispatched" },
        { id: "inProgress", label: "In progress" },
        { id: "completed", label: "Completed" },
        { id: "canceled", label: "Canceled" },
      ],
    },
    {
      id: "priorityFilter",
      type: "singleSelect",
      label: "Priority",
      options: [
        { id: "high", label: "High" },
        { id: "normal", label: "Normal" },
        { id: "low", label: "Low" },
      ],
      // The drawer renders a Radio.Group instead of a select field.
      simpleDrawerVariant: true,
    },
  ]);

  // Demo-only wrapper: keeps the preview wide enough to show inline filters.
  return (
    <div style={{ minWidth: "800px", height: "320px" }}>
      <FilterBar
        associatedContent="jobs table"
        filters={filters}
        onFilterChange={setFilters}
      />
    </div>
  );
}

export default App;
```

#### Multi Select

Multi select filters are backed by `MultiSelectMenuSync` (toolbar) and `MultiSelectFieldSync` (drawer). Same `options`/`filter` model as single select, with `selectedOptions` as an array. `selectAll` and `selectFiltered` are supported and managed automatically — pass `true` (or `{ label }`) to enable.

filter-bar-multi-select

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

```tsx lines expandable theme={null}
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState } from "react";

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "businessUnitFilter",
      type: "multiSelect",
      label: "Business unit",
      options: [
        { id: "plumbing", label: "Plumbing" },
        { id: "hvac", label: "HVAC" },
        { id: "electrical", label: "Electrical" },
        { id: "waterTreatment", label: "Water treatment" },
      ],
      selectedOptions: [{ id: "plumbing", label: "Plumbing" }],
      selectAll: true,
      selectFiltered: true,
    },
    {
      id: "tagFilter",
      type: "multiSelect",
      label: "Tags",
      options: [
        { id: "membership", label: "Membership" },
        { id: "warranty", label: "Warranty" },
        { id: "callback", label: "Callback" },
      ],
      selectedOptions: [],
      // The drawer renders a Checkbox.Group instead of a select field.
      simpleDrawerVariant: true,
    },
  ]);

  // Demo-only wrapper: keeps the preview wide enough to show inline filters.
  return (
    <div style={{ minWidth: "800px", height: "360px" }}>
      <FilterBar
        associatedContent="jobs table"
        filters={filters}
        onFilterChange={setFilters}
      />
    </div>
  );
}

export default App;
```

#### Single Date Selection

filter-bar-date

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

```tsx lines expandable theme={null}
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState } from "react";

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "scheduledDateFilter",
      type: "date",
      label: "Scheduled date",
      mode: "mm/dd/yyyy",
      value: "2025-08-15",
    },
    {
      id: "completedDateFilter",
      type: "date",
      label: "Completed date",
      mode: "mm/dd/yyyy",
      // Dates outside this window are disabled in the calendar.
      minDate: "2025-08-01",
      maxDate: "2025-12-31",
    },
  ]);

  // Demo-only wrapper: keeps the preview wide enough to show inline filters.
  return (
    <div style={{ minWidth: "800px", height: "480px" }}>
      <FilterBar
        associatedContent="jobs table"
        filters={filters}
        onFilterChange={setFilters}
      />
    </div>
  );
}

export default App;
```

#### Range Date Selection

filter-bar-date-range

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

```tsx lines expandable theme={null}
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState } from "react";

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "invoiceDateFilter",
      type: "dateRange",
      label: "Invoice date",
      mode: "mm/dd/yyyy",
      value: { startDate: "2025-08-04", endDate: "2025-08-15" },
    },
    {
      id: "dueDateFilter",
      type: "dateRange",
      label: "Due date",
      mode: "mm/dd/yyyy",
      // Dates outside this window are disabled in the calendar.
      minDate: "2025-08-15",
      maxDate: "2025-12-31",
    },
  ]);

  // Demo-only wrapper: keeps the preview wide enough to show inline filters.
  return (
    <div style={{ minWidth: "800px", height: "480px" }}>
      <FilterBar
        associatedContent="invoices table"
        filters={filters}
        onFilterChange={setFilters}
      />
    </div>
  );
}

export default App;
```

#### Date Constraints

Both `date` and `dateRange` filters accept `minDate`, `maxDate` (ISO 8601 strings), and `unavailable` to restrict which dates are selectable in the calendar. These constraints apply in both the toolbar popover and the drawer.

Typing a value outside the constraints is still allowed, but the field shows an error message. Provide `getErrorMessage` to override the default copy:

filter-bar-date-constraints

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

```tsx lines expandable theme={null}
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState } from "react";

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "dueDateFilter",
      type: "date",
      label: "Due date",
      mode: "mm/dd/yyyy",
      minDate: "2025-08-15",
      unavailable: {
        // Specific dates (ISO 8601) and/or days of the week (1 = Monday … 7 = Sunday).
        dates: ["2025-08-25"],
        daysOfWeek: [6, 7],
      },
      getErrorMessage: ({ reason }) =>
        reason === "minDate"
          ? "Choose a date on or after August 15, 2025."
          : "This date can't be selected.",
    },
    {
      id: "serviceWindowFilter",
      type: "dateRange",
      label: "Service window",
      mode: "mm/dd/yyyy",
      minDate: "2025-08-15",
      maxDate: "2025-12-31",
      getErrorMessage: (context) => {
        if (context.reason === "startAfterEnd") {
          return "The start date must be before the end date.";
        }
        return context.field === "start"
          ? "Choose a start date within the service window."
          : "Choose an end date within the service window.";
      },
    },
  ]);

  // Demo-only wrapper: keeps the preview wide enough to show inline filters.
  return (
    <div style={{ minWidth: "800px", height: "480px" }}>
      <FilterBar
        associatedContent="jobs table"
        filters={filters}
        onFilterChange={setFilters}
      />
    </div>
  );
}

export default App;
```

For a `dateRange` filter, `getErrorMessage` also receives a `field` (`"start"` or `"end"`) identifying the offending endpoint, plus a `"startAfterEnd"` reason when the start date is after the end date.

#### Date List Selection

A Date List filter renders a single-select picker of date "buckets" that
resolve to a concrete date or range. Pass the buckets you want as
`options`; FilterBar automatically renders `On…`, `Before…`, `After…`,
and `Custom Range…` below them. Selecting a menu option opens a Dialog
with the appropriate date picker. Pass `customRange.minDate`,
`customRange.maxDate`, and `customRange.unavailable` to constrain the
Custom Range picker the same way as a `dateRange` filter. On, Before,
After, and consumer-supplied presets are not validated against those
bounds. Typing a value outside `customRange` is still allowed: the field
shows the default `dateRange` constraint message, and Apply remains
available once both endpoints are present. `dateList` does not accept
`getErrorMessage`.

filter-bar-date-list

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

```tsx lines expandable theme={null}
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState } from "react";

// ISO 8601 date string (local calendar day) for `daysAgo` days before today.
// Avoid `toISOString()`: it reports the UTC day, which can be off by one.
function isoDate(daysAgo = 0) {
  const date = new Date();
  date.setDate(date.getDate() - daysAgo);
  const yyyy = date.getFullYear();
  const mm = String(date.getMonth() + 1).padStart(2, "0");
  const dd = String(date.getDate()).padStart(2, "0");
  return `${yyyy}-${mm}-${dd}`;
}

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "dueDateFilter",
      type: "dateList",
      label: "Due date",
      mode: "mm/dd/yyyy",
      // On…, Before…, After…, and Custom Range… are appended automatically.
      options: [
        { id: "any", label: "Any time", value: null },
        { id: "today", label: "Today", value: isoDate() },
        { id: "yesterday", label: "Yesterday", value: isoDate(1) },
        {
          id: "last7Days",
          label: "Last 7 days",
          value: { startDate: isoDate(6), endDate: isoDate() },
        },
        {
          id: "last30Days",
          label: "Last 30 days",
          value: { startDate: isoDate(29), endDate: isoDate() },
        },
      ],
    },
  ]);

  // Demo-only wrapper: keeps the preview wide enough to show inline filters.
  return (
    <div style={{ minWidth: "800px", height: "440px" }}>
      <FilterBar
        associatedContent="invoices table"
        filters={filters}
        onFilterChange={setFilters}
      />
    </div>
  );
}

export default App;
```

An option with `value: null` is treated as no filter applied — selecting it clears the filter and the trigger button shows only the filter label. The four menu option ids (`on`, `before`, `after`, `customRange`) are reserved — don't reuse them in your own `options`.

`on`, `before`, and `after` set `selectedOption` to the operator and the ISO date (`"YYYY-MM-DD"`) the user picked, e.g. `{ id: "before", label: "Before", value: "2026-09-12" }`. `selectedOption.value` is not shifted by a day.

* **On** matches the selected day (`recordDate === selectedOption.value`).
* **Before** matches days before it (`recordDate < selectedOption.value`).
* **After** matches days after it (`recordDate > selectedOption.value`).

If the records you're filtering use timestamps (`Date` or ISO datetime strings) rather than date-only strings, compare against the start of the selected day and the start of the next day so **After** excludes later times on the selected day:

```tsx theme={null}
function matchesTimestamp(
  recordTimestamp: Date,
  selectedOption: { id: string; value: string },
) {
  const [year, month, day] = selectedOption.value.split("-").map(Number);
  const startOfSelectedDay = new Date(year, month - 1, day);
  const startOfNextDay = new Date(year, month - 1, day + 1);

  if (selectedOption.id === "before") {
    return recordTimestamp < startOfSelectedDay;
  }
  if (selectedOption.id === "after") {
    return recordTimestamp >= startOfNextDay;
  }
  return (
    recordTimestamp >= startOfSelectedDay &&
    recordTimestamp < startOfNextDay
  );
}
```

#### Async Select Selection

An Async Select filter is a single-select filter whose options are loaded asynchronously by your `loadOptions` function. In the toolbar it renders as a popover-style menu backed by `SelectMenu`; in the drawer it renders as a labeled form field backed by `SelectField`. Both surfaces consume the same `loadOptions` configuration, including SelectMenu's full lazy loader union (`page`, `offset`, `group`) and eager loading.

filter-bar-async-select

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

```tsx lines expandable theme={null}
import type { SelectMenuOption } from "@servicetitan/anvil2";
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState } from "react";

const technicians: SelectMenuOption[] = [
  { id: "1", label: "Alice Chen" },
  { id: "2", label: "Bob Kumar" },
  { id: "3", label: "Carol Park" },
  { id: "4", label: "Dan Reeves" },
  { id: "5", label: "Eve Martinez" },
];

// Stands in for a server request.
async function fetchTechnicians(searchValue: string) {
  await new Promise((resolve) => setTimeout(resolve, 400));
  const query = searchValue.toLowerCase();
  return technicians.filter((tech) => tech.label.toLowerCase().includes(query));
}

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "technicianFilter",
      type: "asyncSelect",
      label: "Technician",
      loadOptions: fetchTechnicians,
    },
  ]);

  // Demo-only wrapper: keeps the preview wide enough to show inline filters.
  return (
    <div style={{ minWidth: "800px", height: "360px" }}>
      <FilterBar
        associatedContent="jobs table"
        filters={filters}
        onFilterChange={setFilters}
      />
    </div>
  );
}

export default App;
```

Use this for filters whose option set is too large for an in-memory `singleSelect`, or where the options are server-backed (typeahead lookups, dynamic catalogs, etc.). All optional SelectMenu configuration — `pinned`, `cache`, `virtualize`, `disableSearch`, `displayMenuAs`, `groupToString`, etc. — is accepted directly on the filter object.

#### Async Multi Select Selection

An Async Multi Select filter is the multi-selection counterpart of Async Select — backed by `MultiSelectMenu` in the toolbar and `MultiSelectField` in the drawer. Same `loadOptions` configuration, same lazy loader union; the committed value is an array of selected options.

filter-bar-async-multi-select

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

```tsx lines expandable theme={null}
import type { MultiSelectMenuOption } from "@servicetitan/anvil2";
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState } from "react";

const technicians: MultiSelectMenuOption[] = [
  { id: "1", label: "Alice Chen" },
  { id: "2", label: "Bob Kumar" },
  { id: "3", label: "Carol Park" },
  { id: "4", label: "Dan Reeves" },
  { id: "5", label: "Eve Martinez" },
];

// Stands in for a server request.
async function fetchTechnicians(searchValue: string) {
  await new Promise((resolve) => setTimeout(resolve, 400));
  const query = searchValue.toLowerCase();
  return technicians.filter((tech) => tech.label.toLowerCase().includes(query));
}

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "assignedTechniciansFilter",
      type: "asyncMultiSelect",
      label: "Assigned technicians",
      selectedOptions: [],
      loadOptions: fetchTechnicians,
    },
  ]);

  // Demo-only wrapper: keeps the preview wide enough to show inline filters.
  return (
    <div style={{ minWidth: "800px", height: "360px" }}>
      <FilterBar
        associatedContent="jobs table"
        filters={filters}
        onFilterChange={setFilters}
      />
    </div>
  );
}

export default App;
```

The `selectAll` and `selectFiltered` props from `MultiSelectMenu` are supported. Their `onClick` handlers are consumer-owned (you decide what "select all" means against your data source), and their `checkState` is derived from the current `selectedOptions` on each render — rebuild the filter object whenever the selection changes so the checkbox state stays in sync.

#### Tree

A Tree filter selects nodes from a hierarchical, static `options` tree. In the toolbar it renders a popover-style menu backed by `TreeSelectMenuSync`; in the drawer it renders a labeled form field backed by `TreeSelectFieldSync`. Client-side search happens automatically (parents of matches are preserved); customize it with a `filter` function or `MatchSorterOptions` config.

filter-bar-tree

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

```tsx lines expandable theme={null}
import {
  FilterBar,
  type Filter,
  type TreeSelectMenuNode,
} from "@servicetitan/anvil2/beta";
import { useState } from "react";

const departments: TreeSelectMenuNode[] = [
  {
    id: "field",
    label: "Field operations",
    children: [
      {
        id: "plumbing",
        label: "Plumbing",
        children: [
          { id: "plumbing-install", label: "Install" },
          { id: "plumbing-service", label: "Service" },
        ],
      },
      {
        id: "hvac",
        label: "HVAC",
        children: [
          { id: "hvac-install", label: "Install" },
          { id: "hvac-maintenance", label: "Maintenance" },
        ],
      },
    ],
  },
  {
    id: "office",
    label: "Office",
    children: [
      { id: "dispatch", label: "Dispatch" },
      { id: "csr", label: "Customer service" },
    ],
  },
];

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "departmentFilter",
      type: "tree",
      label: "Department",
      options: departments,
      selectionMode: "linked", // "single" | "independent" | "linked"
      valueConsistsOf: "LEAF_PRIORITY",
      defaultExpandLevel: 1,
    },
  ]);

  // Demo-only wrapper: keeps the preview wide enough to show inline filters.
  return (
    <div style={{ minWidth: "800px", height: "440px" }}>
      <FilterBar
        associatedContent="technicians table"
        filters={filters}
        onFilterChange={setFilters}
      />
    </div>
  );
}

export default App;
```

The committed value is stored as `selectedNodes` — an array of node values, even in `selectionMode: "single"`. All optional `TreeSelectMenu` configuration (`selectionMode`, `valueConsistsOf`, `defaultExpandLevel`, `expandedIds`, `virtualize`, `disableSearch`, `displayMenuAs`, `popoverWidth`, `searchPlaceholder`) is accepted directly on the filter object.

#### Async Tree

An Async Tree filter is the asynchronous counterpart of Tree — backed by `TreeSelectMenu` in the toolbar and `TreeSelectField` in the drawer. Instead of a static `options` tree, you supply a `loadOptions` callback that is called for the root nodes and again with a `parentNode` for lazy branch expansion.

filter-bar-async-tree

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

```tsx lines expandable theme={null}
import {
  FilterBar,
  type Filter,
  type TreeSelectMenuNode,
} from "@servicetitan/anvil2/beta";
import { useState } from "react";

// Flat "server" data: each node knows its parent.
const orgChart = [
  { id: "field", label: "Field operations", parentId: null },
  { id: "plumbing", label: "Plumbing", parentId: "field" },
  { id: "hvac", label: "HVAC", parentId: "field" },
  { id: "alice", label: "Alice Chen", parentId: "plumbing" },
  { id: "bob", label: "Bob Kumar", parentId: "plumbing" },
  { id: "carol", label: "Carol Park", parentId: "hvac" },
  { id: "office", label: "Office", parentId: null },
  { id: "dan", label: "Dan Reeves", parentId: "office" },
];

function toNode(record: (typeof orgChart)[number]): TreeSelectMenuNode {
  const childCount = orgChart.filter((r) => r.parentId === record.id).length;
  // `children: null` marks a branch whose children load on expand.
  return childCount > 0
    ? { id: record.id, label: record.label, children: null, childCount }
    : { id: record.id, label: record.label };
}

// Stands in for a server request. Called with no `parentNode` for the roots
// (or a search), and with `parentNode` when a branch expands.
async function fetchOrgChart(
  searchValue: string,
  parentNode?: TreeSelectMenuNode,
): Promise<TreeSelectMenuNode[]> {
  await new Promise((resolve) => setTimeout(resolve, 400));

  if (parentNode) {
    return orgChart.filter((r) => r.parentId === parentNode.id).map(toNode);
  }

  if (searchValue) {
    const query = searchValue.toLowerCase();
    return orgChart
      .filter((r) => r.label.toLowerCase().includes(query))
      .map((r) => ({ id: r.id, label: r.label }));
  }

  return orgChart.filter((r) => r.parentId === null).map(toNode);
}

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "orgChartFilter",
      type: "asyncTree",
      label: "Org chart",
      loadOptions: fetchOrgChart,
      selectionMode: "linked",
      valueConsistsOf: "LEAF_PRIORITY",
      initialLoad: "open",
    },
  ]);

  // Demo-only wrapper: keeps the preview wide enough to show inline filters.
  return (
    <div style={{ minWidth: "800px", height: "400px" }}>
      <FilterBar
        associatedContent="technicians table"
        filters={filters}
        onFilterChange={setFilters}
      />
    </div>
  );
}

export default App;
```

Use this for trees too large to load up front, or whose branches are server-backed. The committed value is stored as `selectedNodes`, the same shape as the sync `tree` filter. All optional `TreeSelectMenu` configuration — `cache`, `virtualize`, `displayMenuAs`, `initialLoad`, `selectionMode`, `valueConsistsOf`, `defaultExpandLevel`, etc. — is accepted directly on the filter object.

These filter types come with preset filter drawer variants.

### Custom Filters

filter-bar-custom

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

```tsx lines expandable theme={null}
import { Radio } from "@servicetitan/anvil2";
import {
  FilterBar,
  type CustomFilter,
  type Filter,
  type FilterRenderProps,
} from "@servicetitan/anvil2/beta";
import { useState } from "react";

type Rating = { id: string; label: string };

const ratings: Rating[] = [
  { id: "5", label: "5 stars" },
  { id: "4", label: "4 stars and up" },
  { id: "3", label: "3 stars and up" },
];

// Shared by the toolbar popover and the drawer; `name` keeps the two
// radio groups independent when both are mounted.
function RatingPicker({
  name,
  value,
  onChange,
}: FilterRenderProps<Rating> & { name: string }) {
  return (
    <Radio.Group legend="Minimum rating">
      {ratings.map((rating) => (
        <Radio
          key={rating.id}
          name={name}
          value={rating.id}
          label={rating.label}
          checked={value?.id === rating.id}
          onChange={() => onChange(rating)}
        />
      ))}
    </Radio.Group>
  );
}

const ratingFilter: CustomFilter<Rating> = {
  id: "ratingFilter",
  type: "custom",
  label: "Rating",
  buttonRender: (props) => <RatingPicker name="rating-popover" {...props} />,
  drawerRender: (props) => <RatingPicker name="rating-drawer" {...props} />,
};

function App() {
  const [filters, setFilters] = useState<Filter[]>([ratingFilter]);

  // Demo-only wrapper: keeps the preview wide enough to show inline filters.
  return (
    <div style={{ minWidth: "800px", height: "280px" }}>
      <FilterBar
        associatedContent="reviews table"
        filters={filters}
        onFilterChange={(updatedFilters) =>
          setFilters(
            // Reflect the committed value in the trigger label.
            updatedFilters.map((filter) =>
              filter.id === "ratingFilter" && filter.type === "custom"
                ? {
                    ...filter,
                    label: filter.value
                      ? `Rating: ${(filter.value as Rating).label}`
                      : "Rating",
                  }
                : filter,
            ),
          )
        }
      />
    </div>
  );
}

export default App;
```

Provide a `buttonRender`, `drawerRender`, and custom `label` to create a custom filter. Label's for custom filters can either be a string or a string with a chip count. Custom filters can be used by themselves or as part of a combined filter array.

### Drawer-only Filters

filter-bar-drawer-only

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

```tsx lines expandable theme={null}
import { TextField } from "@servicetitan/anvil2";
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState } from "react";

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "activeFilter",
      type: "boolean",
      label: "Active only",
      checked: false,
    },
    {
      id: "businessUnitFilter",
      type: "multiSelect",
      label: "Business unit",
      options: [
        { id: "plumbing", label: "Plumbing" },
        { id: "hvac", label: "HVAC" },
        { id: "electrical", label: "Electrical" },
      ],
      selectedOptions: [],
    },
    // Never rendered inline — only reachable from the drawer.
    {
      id: "tagFilter",
      type: "multiSelect",
      label: "Tags",
      options: [
        { id: "membership", label: "Membership" },
        { id: "warranty", label: "Warranty" },
        { id: "callback", label: "Callback" },
      ],
      selectedOptions: [],
      drawerOnly: true,
    },
    // Drawer-only custom filters take `drawerRender` only; `buttonRender`
    // is not allowed.
    {
      id: "poNumberFilter",
      type: "custom",
      label: "PO number",
      drawerOnly: true,
      drawerRender: ({ value, onChange }) => (
        <TextField
          label="PO number"
          value={(value as string | undefined) ?? ""}
          onChange={(e) => onChange(e.target.value || undefined)}
        />
      ),
    },
  ]);

  // Demo-only wrapper: keeps the preview wide enough to show inline filters.
  return (
    <div style={{ minWidth: "800px" }}>
      <FilterBar
        associatedContent="jobs table"
        filters={filters}
        onFilterChange={setFilters}
      />
    </div>
  );
}

export default App;
```

Set `drawerOnly: true` on any filter to keep it accessible only through the filter drawer. The filter never renders inline in the toolbar, even when there is room for it. The `drawerOnly` flag is available on every filter type — `boolean`, `singleSelect`, `multiSelect`, `date`, `dateRange`, `dateList`, `asyncSelect`, `asyncMultiSelect`, `tree`, `asyncTree`, `numericRange`, `textInput`, `search`, and `custom`.

For a custom filter with `drawerOnly: true`, `buttonRender` is forbidden — only `drawerRender` is needed, since the filter never renders inline.

The drawer's "Filters" trigger appears whenever at least one filter is unreachable from the toolbar — that is, when a filter is drawer-only or the container is too narrow (below 640px) to show inline filters. When every filter fits inline and none are drawer-only, the trigger does not render.

### Filters with Apply/Cancel Triggers

filter-bar-controlled-filtering

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

```tsx lines expandable theme={null}
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState } from "react";

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "businessUnitFilter",
      type: "multiSelect",
      label: "Business unit",
      options: [
        { id: "plumbing", label: "Plumbing" },
        { id: "hvac", label: "HVAC" },
        { id: "electrical", label: "Electrical" },
      ],
      selectedOptions: [],
    },
    {
      id: "statusFilter",
      type: "singleSelect",
      label: "Status",
      options: [
        { id: "scheduled", label: "Scheduled" },
        { id: "completed", label: "Completed" },
      ],
    },
    {
      id: "scheduledDateFilter",
      type: "dateRange",
      label: "Scheduled date",
      mode: "mm/dd/yyyy",
    },
  ]);

  // Demo-only wrapper: keeps the preview wide enough to show inline filters.
  return (
    <div style={{ minWidth: "800px", height: "360px" }}>
      <FilterBar
        associatedContent="jobs table"
        filters={filters}
        onFilterChange={setFilters}
        // Popovers show Apply and Cancel instead of committing on each change.
        controlledFiltering
      />
    </div>
  );
}

export default App;
```

By default, filters update as soon as a selection is made in the filter button. When `controlledFiltering` is set to true, Apply and Cancel buttons appear at the bottom of single select, multi select, single date selection, range date selection, numeric range, text input, and custom filter button popovers to control updating. This setting has no impact on the Filter Drawer, which always submits as a batch update upon clicking Apply.

### Clear Button

Single select, multi select, async select, async multi select, date, date range, date list, and numeric range filters all render a "Clear" button in their popover footer. Clicking Clear resets the filter to its empty state, fires `onFilterChange`, and closes the menu. The behavior is built-in — the adapter wires up the clear handler, footer layout, and close behavior, and consumers cannot opt out per filter.

Footer layout adapts to what else is present:

* **Clear alone** — full-width row at the bottom of the popover.
* **Clear + Apply/Cancel** — Clear sits to the left of the Apply/Cancel row (multi select, async multi select, date, date range, and numeric range filters when `controlledFiltering` is on).

Clear is also wired to any consumer-provided "Add new item" affordance: with both present, Add-new stacks full-width above the Clear / Apply-Cancel row.

Clear in confirmation mode is immediate — it commits an empty value regardless of any in-progress draft. The Filter Drawer has its own "Clear filters" trigger and is unaffected by this footer change.

### Filters with Additional Toolbar Items

Compose `FilterBar` and `Toolbar` as **siblings** in a horizontal `Flex` row. Because both components default to growing within a flex container, constrain the Toolbar and let the FilterBar take the remaining space — otherwise the Toolbar expands and collapses the filters.

filter-bar-with-toolbar

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

```tsx lines expandable theme={null}
import { Flex } from "@servicetitan/anvil2";
import AttachFile from "@servicetitan/anvil2/assets/icons/material/round/attach_file.svg";
import FormatBold from "@servicetitan/anvil2/assets/icons/material/round/format_bold.svg";
import { FilterBar, Toolbar, type Filter } from "@servicetitan/anvil2/beta";
import { useState } from "react";

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "activeFilter",
      type: "boolean",
      label: "Active only",
      checked: false,
    },
    {
      id: "statusFilter",
      type: "singleSelect",
      label: "Status",
      options: [
        { id: "active", label: "Active" },
        { id: "inactive", label: "Inactive" },
      ],
    },
    {
      id: "categoryFilter",
      type: "multiSelect",
      label: "Category",
      options: [
        { id: "plumbing", label: "Plumbing" },
        { id: "hvac", label: "HVAC" },
        { id: "electrical", label: "Electrical" },
      ],
      selectedOptions: [],
    },
  ]);

  return (
    // minWidth is demo-only so this preview stays wide enough for inline
    // filters. Omit it in your app; width: "100%" is the layout.
    <Flex alignItems="center" style={{ width: "100%", minWidth: "800px" }}>
      <FilterBar
        associatedContent="jobs table"
        filters={filters}
        flexGrow={1}
        onFilterChange={setFilters}
      />
      <Toolbar
        associatedContent="jobs table actions"
        style={{ flex: "0 0 auto", width: "fit-content" }}
      >
        <Toolbar.Button icon={AttachFile} aria-label="Attach file" />
        <Toolbar.Button icon={FormatBold} aria-label="Format bold" />
      </Toolbar>
    </Flex>
  );
}

export default App;
```

Key layout details:

* Put `FilterBar` and `Toolbar` next to each other (not nested). Tab moves between the two toolbars; arrow keys move within each.
* Set `style={{ flex: "0 0 auto", width: "fit-content" }}` on `Toolbar` so it only takes the width of its controls.
* Set `flexGrow={1}` on `FilterBar` (from layout utility props) so filters use the remaining row width.
* Reverse the sibling order if actions belong on the left; keep the same flex sizing rules.

See also the [Toolbar.Filters to FilterBar](https://anvil.servicetitan.com/docs/components/filter-bar/beta-changes/toolbar-filters-to-filter-bar.md) migration guide.

### Responsive Behavior

filter-bar-responsive

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

```tsx lines expandable theme={null}
import { FilterBar, type Filter } from "@servicetitan/anvil2/beta";
import { useState } from "react";

function App() {
  const [filters, setFilters] = useState<Filter[]>([
    {
      id: "activeFilter",
      type: "boolean",
      label: "Active only",
      checked: false,
    },
    {
      id: "statusFilter",
      type: "singleSelect",
      label: "Status",
      options: [
        { id: "scheduled", label: "Scheduled" },
        { id: "completed", label: "Completed" },
      ],
    },
    {
      id: "businessUnitFilter",
      type: "multiSelect",
      label: "Business unit",
      options: [
        { id: "plumbing", label: "Plumbing" },
        { id: "hvac", label: "HVAC" },
        { id: "electrical", label: "Electrical" },
      ],
      selectedOptions: [],
    },
    {
      id: "scheduledDateFilter",
      type: "date",
      label: "Scheduled date",
      mode: "mm/dd/yyyy",
    },
  ]);

  // Below 640px FilterBar normally collapses to the drawer trigger alone.
  // `disableCollapse` keeps the filters inline and lets them wrap instead.
  return (
    <div style={{ width: "320px" }}>
      <FilterBar
        associatedContent="jobs table"
        filters={filters}
        onFilterChange={setFilters}
        disableCollapse
      />
    </div>
  );
}

export default App;
```

`FilterBar` always wraps filters that don't fit on a single line. Below a 640px container width, inline filters are hidden and only the drawer trigger is shown. Set `disableCollapse` to keep filters inline at every container width.

## React Accessibility

### Aria Labels

Add an `associatedContent` prop to the `FilterBar` for proper group identification for screen readers. The `associatedContent` value provides a11y context, generating labels like "Filters for {associatedContent}".

## Testing

FilterBar'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 `FilterBar`. It is forwarded to the root element, letting you scope queries to a single instance:

```tsx theme={null}
<FilterBar data-cy="job-filters" associatedContent="jobs" {...rest} />
```

```ts theme={null}
// Open a filter, then target its input within this specific FilterBar
cy.get('[data-cy="job-filters"] [data-anv-part="trigger"]').first().click();
cy.get('[data-anv-part="min-input"]').type("100");
```

Each filter trigger also carries a `data-id` matching the filter's `id`, so you can target a specific filter's trigger with `[data-cy="job-filters"] [data-id="amount"]`.

| `data-anv-part` | Element |
| - | - |
| `trigger` | A filter's inline trigger button or boolean toggle |
| `drawer-trigger` | Button that opens the filter drawer (collapsed mode) |
| `drawer` | The filter drawer container |
| `input` | Text input of a text or search filter |
| `min-input` | Lower-bound input of a numeric range filter |
| `max-input` | Upper-bound input of a numeric range filter |
| `date-input` | Masked text input of a date or date-range filter |
| `date-calendar` | Calendar surface of a date or date-range filter |
| `dialog` | Date-list library dialog container |
| `apply-button` | Button that commits the current selection |
| `cancel-button` | Button that dismisses a menu or drawer without committing |
| `clear-button` | Clears text in a search field (not the whole bar) |
| `clear-filters-button` | Clears all active filters (inline **Clear Filters** / drawer **Clear All Filters**) |
| `count-badge` | Numeric count badge on a filter trigger or drawer trigger (not a value chip) |

Select, multi-select, and tree filters render the corresponding fields, which expose their own parts (`input`, `option`, `chip`, `overflow-chip`, and so on) — see the SelectField and MultiSelectField docs.

> [!NOTE]
>
> Filter menus, drawers, dialogs, and popovers render in the browser's top layer, outside the FilterBar root. Descendant queries scoped to your wrapper attribute will not match parts inside an open menu or drawer (`min-input`, `date-calendar`, `apply-button`, etc.). Open the filter first, then target those parts by their part attribute directly. Only one menu or drawer is open at a time.

**FilterBar Props**

```tsx theme={null}
import { FilterBar } from "@servicetitan/anvil2/beta";

function ExampleComponent() {
return (

<FilterBar
  associatedContent="..."
  filters={filters}
  controlledFiltering={false}
  onFilterChange={(filters) => console.log(filters)}
/>
); }

```

## `FilterBar` Props

**associatedContent** `string` *required*

Describes the content being filtered for accessibility context. Used to generate aria-labels.

**filters** `Filter[]` *required*

Union type of all available filter objects.

**onFilterChange** `(filters: Filter[]) =\> void` *required*

Callback function for when filters change.

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

When enabled, filter button submission is controlled via an apply button.

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

When true, filters stay inline at every container width instead of collapsing to drawer-only mode below 640px.

**size** `"xsmall" | "small" | "medium" | "large"` (default: xsmall)

Controls the size of the filter bar and its filter buttons.

**Boolean Filter Props**

```tsx theme={null}
{
  type: "boolean",
  id: "booleanFilterId",
  label: "Boolean Filter",
  checked: false,
}
```

## Boolean Filter Props

To have a boolean filter, you must have `type: "boolean"` in the filter object in the array.

**type** `boolean`

To have a boolean filter, this type must be in the object.

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

Selected state of the filter

**id** `string`

Unique identifier for filter

**label** `string`

Label for filter

**Search Filter Props**

```tsx theme={null}
{
  type: "search",
  id: "searchFilterId",
  label: "Search",
  placeholder: "Search...",
}
```

## Search Filter Props

To have a search filter, you must have `type: "search"` in the filter object in the array. `FilterBar` always renders it first among the inline filters, regardless of where it sits in the `filters` array, since keyboard navigation treats it as a fixed edge of the filter sequence.

**id** `string` *required*

Unique identifier for the filter

**label** `string` *required*

Display label for the filter

**type** `"search"` *required*

Identifies this as a search filter

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

When true, the filter is only reachable via the drawer and never rendered inline in the toolbar. Available on every filter type.

**placeholder** `string`

Placeholder shown when the field is empty.

**value** `string`

Current text value. Undefined or an empty string means inactive.

**Single Select Filter Props**

```tsx theme={null}
{
  type: "singleSelect",
  id: "singleSelectFilterId",
  label: "Status",
  options: [{ id: "active", label: "Active" }],
}
```

## Single Select Filter Props

To have a single select filter, you must have `type: "singleSelect"` in the filter object in the array. The filter renders `SelectMenuSync` in the toolbar and `SelectFieldSync` in the drawer; client-side filtering happens automatically via match-sorter. The filter accepts the full `SelectMenuSyncProps` shape — every optional configuration (`filter`, `groupSorter`, `groupToString`, `pinned`, `virtualize`, `disableSearch`, `displayMenuAs`, `popoverWidth`, `searchPlaceholder`, `addItemLabel`, `onAddNewItem`) is supported.

**id** `string` *required*

Unique identifier for the filter

**label** `string` *required*

Display label for the filter

**options** `SelectMenuOption[]` *required*

Array of options to select from. Filtered client-side via match-sorter.

**type** `"singleSelect"` *required*

Identifies this as a single select filter

**filter** `SyncFilterFn | MatchSorterOptions\<SelectMenuOption\>`

Custom filter behavior — either a function that returns options in the desired display order, or a `MatchSorterOptions` config. Defaults to match-sorter over `label` and `searchText`.

**selectedOption** `SelectMenuOption`

Currently selected option.

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

When true, the drawer cell renders a `Radio.Group` over the full option set instead of `SelectFieldSync`. The toolbar still uses `SelectMenuSync` regardless.

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

When true, the filter is only reachable via the drawer and never rendered inline in the toolbar. Available on every filter type.

**Multi Select Filter Props**

```tsx theme={null}
{
  type: "multiSelect",
  id: "multiSelectFilterId",
  label: "Categories",
  options: [{ id: "bug", label: "Bug" }],
  selectedOptions: [],
  selectAll: true,
  selectFiltered: true,
}
```

## Multi Select Filter Props

To have a multi select filter, you must have `type: "multiSelect"` in the filter object in the array. The filter renders `MultiSelectMenuSync` in the toolbar and `MultiSelectFieldSync` in the drawer. Client-side filtering and the `selectAll` / `selectFiltered` affordances are managed automatically.

**id** `string` *required*

Unique identifier for the filter

**label** `string` *required*

Display label for the filter

**options** `MultiSelectMenuOption[]` *required*

Array of options to select from. Filtered client-side via match-sorter.

**type** `"multiSelect"` *required*

Identifies this as a multi select filter

**filter** `SyncFilterFn | MatchSorterOptions\<MultiSelectMenuOption\>`

Custom filter behavior — either a function that returns options in the desired display order, or a `MatchSorterOptions` config. Defaults to match-sorter over `label` and `searchText`.

**selectedOptions** `MultiSelectMenuOption[]` (default: \[])

Currently selected options array.

**selectAll** `boolean | \{ label?: string | ((checked: boolean) =\> string) \}`

Enables the "Select All" option at the top of the list (shown when search is empty). Click handling and check state are managed automatically. Pass `{ label }` to customize the label.

**selectFiltered** `boolean | ((searchValue: string) =\> \{ label?: string \})`

Enables the "Select Filtered" option when a search term is active (replaces `selectAll` while searching). Click handling and check state are managed automatically.

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

When true, the drawer cell renders a `Checkbox.Group` over the full option set instead of `MultiSelectFieldSync`. The toolbar still uses `MultiSelectMenuSync` regardless.

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

When true, the filter is only reachable via the drawer and never rendered inline in the toolbar. Available on every filter type.

**Single Date Selection Filter Props**

```tsx theme={null}
{
  type: "date",
  id: "dateFilterId",
  label: "Date Filter",
  mode: "mm/dd/yyyy",
  value: null,
  minDate: "2025-08-15",
  maxDate: "2025-12-31",
}
```

## Single Date Selection Filter Props

To have a single date selection filter, you must have `type: "date"` in the filter object in the array.

**type** `date`

Identifies this as a single date filter

**id** `string`

Unique identifier for the filter

**label** `string`

Display label for the filter

**mode** `"mm/dd/yyyy" | "dd/mm/yyyy"` (default: mm/dd/yyyy)

Date format for the input.

**value** `Date | null` (default: null)

Currently selected date value.

**minDate** `string`

Earliest selectable date (ISO 8601). Dates before it are disabled in the calendar.

**maxDate** `string`

Latest selectable date (ISO 8601). Dates after it are disabled in the calendar.

**unavailable** `\{ dates?: string[]; daysOfWeek?: (1 | 2 | 3 | 4 | 5 | 6 | 7)[] \}`

Specific dates (ISO 8601) and/or days of the week (1 = Monday … 7 = Sunday) that cannot be selected.

**getErrorMessage** `(context: \{ reason: "minDate" | "maxDate" | "unavailable"; value: string \}) =\> string`

Returns the error message shown when a typed value violates a constraint. When omitted, a default message is shown.

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

When true, the filter is only reachable via the drawer and never rendered inline in the toolbar. Available on every filter type.

**Ranged Date Selection Filter Props**

```tsx theme={null}
{
  type: "dateRange",
  id: "dateRangeFilterId",
  label: "Date Range Filter",
  mode: "mm/dd/yyyy",
  value: { start: null, end: null },
  minDate: "2025-08-15",
  maxDate: "2025-12-31",
}
```

## Ranged Date Selection Filter Props

To have a ranged date selection filter, you must have `type: "dateRange"` in the filter object in the array.

**type** `dateRange`

Identifies this as a date range filter

**id** `string`

Unique identifier for the filter

**label** `string`

Display label for the filter

**mode** `"mm/dd/yyyy" | "dd/mm/yyyy"` (default: mm/dd/yyyy)

Date format for the inputs.

**value** `\{ start: Date | null; end: Date | null \}` (default: \{ start: null, end: null })

Currently selected date range.

**minDate** `string`

Earliest selectable date (ISO 8601). Dates before it are disabled in the calendars.

**maxDate** `string`

Latest selectable date (ISO 8601). Dates after it are disabled in the calendars.

**unavailable** `\{ dates?: string[]; daysOfWeek?: (1 | 2 | 3 | 4 | 5 | 6 | 7)[] \}`

Specific dates (ISO 8601) and/or days of the week (1 = Monday … 7 = Sunday) that cannot be selected.

**getErrorMessage** `(context: \{ reason: "minDate" | "maxDate" | "unavailable"; field: "start" | "end"; value: string \} | \{ reason: "startAfterEnd" \}) =\> string`

Returns the error message shown when a typed value violates a constraint. `field` identifies the offending endpoint; the `"startAfterEnd"` reason indicates the start date is after the end date. When omitted, a default message is shown.

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

When true, the filter is only reachable via the drawer and never rendered inline in the toolbar. Available on every filter type.

**Date List Filter Props**

```tsx theme={null}
{
  type: "dateList",
  id: "dueDateFilterId",
  label: "Due date",
  mode: "mm/dd/yyyy",
  customRange: {
    minDate: "2025-01-01",
    maxDate: "2025-12-31",
  },
  options: [
    { id: "today", label: "Today", value: "2025-08-15" },
  ],
}
```

## Date List Filter Props

To have a date list filter, you must have `type: "dateList"` in the filter object in the array. FilterBar automatically renders four library options (`On…`, `Before…`, `After…`, `Custom Range…`) below the consumer-supplied options; each opens a Dialog with the appropriate date picker. Use `excludeDefaultOptions` to omit any of those built-in options — for example, `["before", "after"]` when the backend does not support open-ended date queries. `customRange.minDate`, `customRange.maxDate`, and `customRange.unavailable` apply to the Custom Range picker only and do not validate On, Before, After, or consumer-supplied preset options. Typing a value outside those constraints is still allowed; the field shows the default `dateRange` error copy (`dateList` has no `getErrorMessage`), and Apply still commits once both endpoints are present.

**id** `string` *required*

Unique identifier for the filter

**label** `string` *required*

Display label for the filter

**options** `DateListOption[]` *required*

Consumer-supplied options. Each option commits its `value` immediately on click. The reserved ids `on`, `before`, `after`, and `customRange` are used by the library and must not appear in consumer options.

**type** `"dateList"` *required*

Identifies this as a date list filter

**customRange** `\{ minDate?: string; maxDate?: string; unavailable?: \{ dates?: string[]; daysOfWeek?: (1 | 2 | 3 | 4 | 5 | 6 | 7)[] \} \}`

Constraints for the built-in Custom Range picker. `minDate` and `maxDate` are ISO 8601 strings. Dates outside that window (and dates listed in `unavailable`) are disabled in the Custom Range calendar. Typing a value outside the constraints is still allowed; the field shows the default `dateRange` error copy, and Apply still commits once both endpoints are present. `dateList` does not accept `getErrorMessage`. Does not constrain On, Before, After, or consumer-supplied preset options.

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

When true, the filter is only reachable via the drawer and never rendered inline in the toolbar. Available on every filter type.

**excludeDefaultOptions** `Array\<"on" | "before" | "after" | "customRange"\>`

Built-in default option ids to omit from the menu. Defaults to showing all four library options.

**mode** `"mm/dd/yyyy" | "dd/mm/yyyy"` (default: mm/dd/yyyy)

Date format for the date pickers.

**selectedOption** `DateListOption`

The currently selected option (consumer or library). For `on`, `before`, and `after`, `value` is the ISO date the user picked (not shifted by a day).

**Date List Option**

```tsx theme={null}
{
  id: "today",
  label: "Today",
  value: "2025-08-15",
}
```

## Date List Option

Each entry in a date list filter's `options` array has this shape. An option with `value: null` is treated as no filter applied; selecting it clears the filter.

**id** `string` *required*

Unique identifier for the option. The reserved ids `on`, `before`, `after`, and `customRange` must not be used.

**label** `string` *required*

Display label for the option.

**value** `string | \{ startDate: string; endDate: string \} | null` *required*

Concrete value the option resolves to: an ISO date string, a date range, or `null` to represent "no filter applied".

**Async Select Filter Props**

```tsx theme={null}
{
  type: "asyncSelect",
  id: "ownerFilterId",
  label: "Owner",
  loadOptions: async (searchValue) => fetchOwners(searchValue),
}
```

## Async Select Filter Props

To have an async select filter, you must have `type: "asyncSelect"` in the filter object in the array. The filter renders `SelectMenu` in the toolbar and `SelectField` in the drawer; both share the same `loadOptions` configuration. The filter accepts the full `SelectMenuProps` union — every option-loading mode (eager + lazy `page` / `offset` / `group`) and every optional configuration (`pinned`, `cache`, `virtualize`, `displayMenuAs`, `disableSearch`, `popoverWidth`, `groupToString`, `groupSorter`, `searchPlaceholder`, `debounceMs`, `initialLoad`, `addItemLabel`, `onAddNewItem`) is supported.

**id** `string` *required*

Unique identifier for the filter

**label** `string` *required*

Display label for the filter

**loadOptions** `(searchValue, ...) =\> SelectFieldOption[] | Promise\<...\>` *required*

Function that returns the options for the current search value. Shape varies with the `lazy` mode — see `SelectMenu` for the full loader signatures.

**type** `"asyncSelect"` *required*

Identifies this as an async select filter

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

Lazy-loading mode. When set, `loadOptions` is called incrementally as the user scrolls; when omitted, all options are loaded eagerly per search.

**selectedOption** `SelectMenuOption`

The currently selected option.

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

When true, the filter is only reachable via the drawer and never rendered inline in the toolbar. Available on every filter type.

**Async Multi Select Filter Props**

```tsx theme={null}
{
  type: "asyncMultiSelect",
  id: "reviewersFilterId",
  label: "Reviewers",
  selectedOptions: [],
  loadOptions: async (searchValue) => fetchReviewers(searchValue),
}
```

## Async Multi Select Filter Props

To have an async multi-select filter, you must have `type: "asyncMultiSelect"` in the filter object in the array. The filter renders `MultiSelectMenu` in the toolbar and `MultiSelectField` in the drawer; both share the same `loadOptions` configuration. The filter accepts the full `MultiSelectMenuProps` union — every option-loading mode (eager + lazy `page` / `offset` / `group`) and every optional configuration (`pinned`, `cache`, `virtualize`, `displayMenuAs`, `disableSearch`, `popoverWidth`, `groupToString`, `groupSorter`, `searchPlaceholder`, `debounceMs`, `initialLoad`, `selectAll`, `selectFiltered`, `addItemLabel`, `onAddNewItem`) is supported.

**id** `string` *required*

Unique identifier for the filter

**label** `string` *required*

Display label for the filter

**loadOptions** `(searchValue, ...) =\> MultiSelectFieldOption[] | Promise\<...\>` *required*

Function that returns the options for the current search value. Shape varies with the `lazy` mode — see `MultiSelectMenu` for the full loader signatures.

**type** `"asyncMultiSelect"` *required*

Identifies this as an async multi-select filter

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

Lazy-loading mode. When set, `loadOptions` is called incrementally as the user scrolls; when omitted, all options are loaded eagerly per search.

**selectedOptions** `MultiSelectMenuOption[]` (default: \[])

The currently selected options.

**selectAll** `BulkActionConfig`

Configuration for the "Select All" checkbox shown above the option list when the search input is empty. Pass exactly one of `onClick` (consumer commits via `onSelectedOptionsChange`) or `compute(current) => next | Promise<next>` (the menu/field commits the result and shows a loading state while the promise resolves). `checkState` should be derived from the current selection.

**selectFiltered** `(searchValue: string) =\> BulkActionConfig`

Configuration for the "Select Filtered" checkbox shown above the option list when a search term is active (replaces `selectAll` while searching). Called with the current search value so the consumer can scope the action to matching results. Accepts the same `onClick` / `compute` choice as `selectAll`.

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

When true, the filter is only reachable via the drawer and never rendered inline in the toolbar. Available on every filter type.

**Tree Filter Props**

```tsx theme={null}
{
  type: "tree",
  id: "departmentFilterId",
  label: "Department",
  options: [
    {
      id: "eng",
      label: "Engineering",
      children: [{ id: "fe", label: "Frontend" }],
    },
  ],
  selectionMode: "linked",
  valueConsistsOf: "LEAF_PRIORITY",
  defaultExpandLevel: 1,
}
```

## Tree Filter Props

To have a tree filter, you must have `type: "tree"` in the filter object in the array. The filter renders `TreeSelectMenuSync` in the toolbar and `TreeSelectFieldSync` in the drawer over a static `options` tree; client-side search happens automatically (parents of matches are preserved). The filter accepts the full `TreeSelectMenuSyncProps` shape — every optional configuration (`filter`, `selectionMode`, `valueConsistsOf`, `defaultExpandLevel`, `expandedIds`, `virtualize`, `disableSearch`, `displayMenuAs`, `popoverWidth`, `searchPlaceholder`) is supported.

**id** `string` *required*

Unique identifier for the filter

**label** `string` *required*

Display label for the filter

**options** `TreeSelectFieldNode[]` *required*

The static tree to display. Each node has `id`, `label`, and optional `children`, `disabled`, `searchText`, `childCount`, and `content`.

**type** `"tree"` *required*

Identifies this as a tree filter

**selectedNodes** `TreeSelectMenuValue[]` (default: \[])

The currently selected node values. Always an array, even in `selectionMode: "single"`.

**selectionMode** `"single" | "independent" | "linked"` (default: "linked")

"single" selects one node at a time. "independent" toggles nodes without cascading. "linked" cascades selection between parents and children.

**valueConsistsOf** `"ALL" | "BRANCH_PRIORITY" | "BRANCH_ONLY" | "LEAF_PRIORITY" | "LEAF_ONLY"` (default: "LEAF\_PRIORITY")

Controls which nodes are selectable and how the value array is shaped.

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

When true, the filter is only reachable via the drawer and never rendered inline in the toolbar. Available on every filter type.

**Async Tree Filter Props**

```tsx theme={null}
{
  type: "asyncTree",
  id: "orgChartFilterId",
  label: "Org Chart",
  loadOptions: async (searchValue, parentNode) =>
    fetchOrgChart(searchValue, parentNode),
  selectionMode: "linked",
  valueConsistsOf: "LEAF_PRIORITY",
  initialLoad: "open",
}
```

## Async Tree Filter Props

To have an async tree filter, you must have `type: "asyncTree"` in the filter object in the array. The filter renders `TreeSelectMenu` in the toolbar and `TreeSelectField` in the drawer; both share the same `loadOptions` configuration, which is called for the root nodes and again with a `parentNode` for lazy branch expansion. The filter accepts the full `TreeSelectMenuProps` shape — every optional configuration (`cache`, `virtualize`, `displayMenuAs`, `disableSearch`, `popoverWidth`, `searchPlaceholder`, `debounceMs`, `initialLoad`, `selectionMode`, `valueConsistsOf`, `defaultExpandLevel`, `expandedIds`) is supported.

**id** `string` *required*

Unique identifier for the filter

**label** `string` *required*

Display label for the filter

**loadOptions** `(searchValue, parentNode?) =\> TreeSelectFieldNode[] | Promise\<...\>` *required*

Function that loads tree nodes. Called for the root nodes, and again with a `parentNode` to lazily load a branch's children.

**type** `"asyncTree"` *required*

Identifies this as an async tree filter

**selectedNodes** `TreeSelectMenuValue[]` (default: \[])

The currently selected node values. Always an array, even in `selectionMode: "single"`.

**initialLoad** `"auto" | "immediate" | "open"`

Controls when `loadOptions` first runs: `"immediate"` on mount, `"open"` when the menu opens, or `"auto"`.

**selectionMode** `"single" | "independent" | "linked"` (default: "linked")

"single" selects one node at a time. "independent" toggles nodes without cascading. "linked" cascades selection between parents and children.

**valueConsistsOf** `"ALL" | "BRANCH_PRIORITY" | "BRANCH_ONLY" | "LEAF_PRIORITY" | "LEAF_ONLY"` (default: "LEAF\_PRIORITY")

Controls which nodes are selectable and how the value array is shaped.

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

When true, the filter is only reachable via the drawer and never rendered inline in the toolbar. Available on every filter type.

**Custom Filter Props**

```tsx theme={null}
{
  type: "custom",
  id: "customFilterId",
  label: "Custom Filter",
  buttonRender: ({ value, onChange }) => <Component />,
  drawerRender: ({ value, onChange }) => <Component />,
  value: null,
  labelChipCount: 0,
}
```

## Custom Filter Props

To have a custom filter, you must have `type: "custom"` in the filter object in the array.

**type** `custom`

Identifies this as a custom filter

**id** `string`

Unique identifier for the filter

**label** `string`

Display label for the filter

**buttonRender** `(props: \{ value?: T; onChange: (value?: T) =\> void \}) =\> ReactNode`

Function to render the filter content in the button/popover. Required unless `drawerOnly` is true, in which case it is forbidden.

**drawerRender** `(props: \{ value?: T; onChange: (value?: T) =\> void \}) =\> ReactNode`

Function to render the filter content in the drawer

**labelChipCount** `number`

Optional number to display in a label chip

**value** `T`

Current value of the custom filter

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

When true, the filter is only reachable via the drawer and never rendered inline in the toolbar. Available on every filter type.


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