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

# Data Table - Web

> Data tables display complex tabular data with built-in support for sorting, pagination, row selection, and expansion.

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

> [!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!

> [!WARNING]
>
> **Migrating from the A1 (Kendo) table?**
>
> Don't assume feature parity. The Anvil2 Data Table follows a phased roadmap and doesn't aim to re-implement every legacy Kendo capability.
>
> Before committing, audit the behaviors you depend on, identify gaps, and plan workarounds.

data-table-playground

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

```tsx lines theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  payment_type: PaymentType;
};

const data: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    order_date: "09/20/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    payment_type: "check",
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    status: ["shipped"],
    order_date: "09/21/2024",
    payment_type: "cash",
  },
];

const createColumn = createColumnHelper<OrderData>();

const statusOrder: Record<Status, number> = {
  pending: 1,
  processing: 2,
  shipped: 3,
  completed: 4,
  cancelled: 5,
};

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
  const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
  const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

  return statusA - statusB;
};

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    sortable: true,
    resizable: true,
    minWidth: 130,
    footerContent: (
      <span>
        <b>Total:</b> {data.length}
      </span>
    ),
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
    footerContent: [
      <span key="total">
        {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
      </span>,
      <span key="average">
        {currencyFormatter(
          data.reduce((acc, row) => acc + row.amount, 0) / data.length,
        )}{" "}
        (avg)
      </span>,
    ],
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
    sortable: sortStatus,
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

function App() {
  return <DataTable data={data} columns={columns} />;
}

export default App;
```

Use row selection with bulk actions when users need to act on many rows at once, such as:

## Anatomy

![Anatomy of DataTable](https://mintcdn.com/servicetitan/G8Md3kIQ0EqRzkMI/images/docs/web/components/data-table/anatomy.png?fit=max\&auto=format\&n=G8Md3kIQ0EqRzkMI\&q=85\&s=23b5072667341f8053ce26abbfef5f93)

The DataTable consists of several key structural elements:

1. **Header row** – Contains column labels with optional sort indicators
2. **Header cell** – Individual column header with label and optional sorting control
3. **Body rows** – Data rows containing cell content
4. **Body cells** – Individual data cells with formatted content
5. **Selection column** – Optional checkbox column for row selection
6. **Expansion column** – Optional expand/collapse control for sub-rows or sub-components. Shifts with content
7. **Footer row** – Optional summary or aggregate data
8. **Pagination controls** – Navigation for paginated data sets
9. **Pinned/locked column** – Column that stays in place as user scrolls

## Options

### Sortable Columns

data-table-sorting

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

```tsx lines theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  payment_type: PaymentType;
};

const data: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    order_date: "09/20/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    payment_type: "check",
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    status: ["shipped"],
    order_date: "09/21/2024",
    payment_type: "cash",
  },
  {
    id: "ORD-2024-008",
    amount: 3780.25,
    status: ["processing"],
    order_date: "09/19/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-009",
    amount: 156.78,
    status: ["completed"],
    order_date: "09/14/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-010",
    amount: 2100.0,
    status: ["shipped", "cancelled"],
    order_date: "09/22/2024",
    payment_type: "credit_card",
  },
];

const createColumn = createColumnHelper<OrderData>();

const statusOrder: Record<Status, number> = {
  pending: 1,
  processing: 2,
  shipped: 3,
  completed: 4,
  cancelled: 5,
};

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
  const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
  const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

  return statusA - statusB;
};

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    sortable: true,
    resizable: true,
    minWidth: 130,
    footerContent: (
      <span>
        <b>Total:</b> {data.length}
      </span>
    ),
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
    footerContent: [
      <span key="total">
        {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
      </span>,
      <span key="average">
        {currencyFormatter(
          data.reduce((acc, row) => acc + row.amount, 0) / data.length,
        )}{" "}
        (avg)
      </span>,
    ],
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
    sortable: sortStatus,
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

function App() {
  return (
    <DataTable
      data={data}
      columns={columns}
      defaultSortedColumn={{ id: "status", desc: false }}
    />
  );
}

export default App;
```

Enable sorting on columns to let users click the header and sort data in ascending or descending order. A sort indicator shows the current sort direction.

Clicking a sortable column header cycles through: ascending → descending → unsorted. Only one column can be sorted at a time.

| Sort State | Indicator |
| - | - |
| Ascending | Arrow up |
| Descending | Arrow down |
| Unsorted | No indicator |

### Resizable Columns

Columns can optionally be made resizable. When this is turned on, users can resize columns by dragging the column border. Configure minimum and maximum widths to constrain resizing.

### Pinned Columns

data-table-pinned-columns

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

```tsx lines theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  payment_type: PaymentType;
};

const data: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    order_date: "09/20/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    payment_type: "check",
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    status: ["shipped"],
    order_date: "09/21/2024",
    payment_type: "cash",
  },
];

const createColumn = createColumnHelper<OrderData>();

const statusOrder: Record<Status, number> = {
  pending: 1,
  processing: 2,
  shipped: 3,
  completed: 4,
  cancelled: 5,
};

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
  const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
  const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

  return statusA - statusB;
};

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    sortable: true,
    resizable: true,
    minWidth: 130,
    footerContent: (
      <span>
        <b>Total:</b> {data.length}
      </span>
    ),
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
    footerContent: [
      <span key="total">
        {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
      </span>,
      <span key="average">
        {currencyFormatter(
          data.reduce((acc, row) => acc + row.amount, 0) / data.length,
        )}{" "}
        (avg)
      </span>,
    ],
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
    sortable: sortStatus,
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

function App() {
  return (
    <DataTable
      data={data}
      columns={columns.map((column, index) => ({
        ...column,
        pinned: index === 0 ? "left" : index === 4 ? "right" : undefined,
      }))}
    />
  );
}

export default App;
```

When tables have many columns requiring horizontal scroll, pin essential columns (like row identifiers or action columns). Pinned columns stay in place during horizontal scrolling, keeping important data visible.

| Pinning | Behavior |
| - | - |
| Left | Column sticks to left edge during horizontal scroll |
| Right | Column sticks to right edge during horizontal scroll |

### Grouped Columns

data-table-grouped-columns

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

```tsx lines theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  payment_type: PaymentType;
};

const data: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    order_date: "09/20/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    payment_type: "check",
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    status: ["shipped"],
    order_date: "09/21/2024",
    payment_type: "cash",
  },
];

const createColumn = createColumnHelper<OrderData>();

const statusOrder: Record<Status, number> = {
  pending: 1,
  processing: 2,
  shipped: 3,
  completed: 4,
  cancelled: 5,
};

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
  const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
  const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

  return statusA - statusB;
};

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

// Define individual columns that will be grouped
const orderInfoColumns = [
  createColumn("id", {
    header: {
      label: "Order ID",
      required: true,
      moreInfo: "Unique order identifier used in downstream workflows.",
    },
    sortable: true,
    resizable: true,
    minWidth: 130,
    footerContent: (
      <span>
        <b>Total:</b> {data.length}
      </span>
    ),
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
    sortable: sortStatus,
  }),
];

const paymentColumns = [
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
    footerContent: [
      <span key="total">
        {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
      </span>,
      <span key="average">
        {currencyFormatter(
          data.reduce((acc, row) => acc + row.amount, 0) / data.length,
        )}{" "}
        (avg)
      </span>,
    ],
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

// Create grouped columns using the group syntax
const groupedColumns = [
  createColumn(
    { group: "order_info" },
    {
      header: {
        label: "Order Info",
        required: true,
        moreInfo: "Columns describing the order record.",
      },
      columns: orderInfoColumns,
    },
  ),
  createColumn(
    { group: "payment_details" },
    {
      header: { label: "Payment Details" },
      columns: paymentColumns,
    },
  ),
];

function App() {
  return <DataTable data={data} columns={groupedColumns} />;
}

export default App;
```

Group columns under a parent header to create a two-level header structure. Use this to organize related data.

Please note that grouped columns cannot be pinned.

### Pagination

data-table-pagination

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

```tsx lines theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  customer_name: string;
  payment_type: PaymentType;
};

const data: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    customer_name: "Sophia Rodriguez",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    customer_name: "TechCorp Solutions",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    order_date: "09/20/2024",
    customer_name: "Ahmed Hassan",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    customer_name: "Maria Gonzalez",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    customer_name: "Chen Wei",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    customer_name: "Green Valley Farms",
    payment_type: "check",
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    status: ["shipped"],
    order_date: "09/21/2024",
    customer_name: "Jennifer Kim",
    payment_type: "cash",
  },
];

const createColumn = createColumnHelper<OrderData>();

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    sortable: true,
    resizable: true,
    minWidth: 130,
  }),
  createColumn("customer_name", {
    header: { label: "Customer" },
    sortable: true,
    resizable: true,
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

function App() {
  return (
    <DataTable
      data={data}
      columns={columns}
      pagination={{
        rowsPerPage: 5,
        defaultPageIndex: 0,
        showCount: true,
      }}
    />
  );
}

export default App;
```

When enabled, pagination controls display at the bottom of the table showing:

* Current page range (e.g., "1-25 of 100")
* Navigation buttons for first, previous, next, and last page

### Cell Types

The DataTable provides the following cell types:

<table>
  <thead>
    <tr>
      <th>Image</th>
      <th>Type of Cell</th>
      <th>Alignment</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>
        ![Action cell type](https://mintcdn.com/servicetitan/G8Md3kIQ0EqRzkMI/images/docs/web/components/data-table/cell-actions-light.png?fit=max\&auto=format\&n=G8Md3kIQ0EqRzkMI\&q=85\&s=c0b7f0121c059e672b936f0f8d44e7ef)
      </td>

      <td>Actions cell</td>
      <td>Left</td>
    </tr>

    <tr>
      <td>
        ![Cell with chip/chips](https://mintcdn.com/servicetitan/G8Md3kIQ0EqRzkMI/images/docs/web/components/data-table/cell-chip-light.png?fit=max\&auto=format\&n=G8Md3kIQ0EqRzkMI\&q=85\&s=1943080e41be62530c48f9c12b1fff85)
      </td>

      <td>Cell with chip/chips</td>
      <td>Left</td>
    </tr>

    <tr>
      <td>
        ![Cell with currency](https://mintcdn.com/servicetitan/G8Md3kIQ0EqRzkMI/images/docs/web/components/data-table/cell-currency-light.png?fit=max\&auto=format\&n=G8Md3kIQ0EqRzkMI\&q=85\&s=8a00f24085a0caf1115dc175819ee9fc)
      </td>

      <td>Cell with currency</td>
      <td>Right</td>
    </tr>

    <tr>
      <td>
        ![Custom cell](https://mintcdn.com/servicetitan/G8Md3kIQ0EqRzkMI/images/docs/web/components/data-table/cell-custom-light.png?fit=max\&auto=format\&n=G8Md3kIQ0EqRzkMI\&q=85\&s=d4a3f21a293d413c4fd198c1a598df3f)
      </td>

      <td>Custom cell</td>
      <td>Left</td>
    </tr>

    <tr>
      <td>
        ![Cell with date](https://mintcdn.com/servicetitan/G8Md3kIQ0EqRzkMI/images/docs/web/components/data-table/cell-date-light.png?fit=max\&auto=format\&n=G8Md3kIQ0EqRzkMI\&q=85\&s=501820a90f1094ed6bc5a5990787e60b)
      </td>

      <td>Cell with date</td>
      <td>Right</td>
    </tr>

    <tr>
      <td>
        ![Cell with date-time](https://mintcdn.com/servicetitan/G8Md3kIQ0EqRzkMI/images/docs/web/components/data-table/cell-datetime-light.png?fit=max\&auto=format\&n=G8Md3kIQ0EqRzkMI\&q=85\&s=344ba1fa68bd9876ee397b08267b0227)
      </td>

      <td>Cell with date-time</td>
      <td>Right</td>
    </tr>

    <tr>
      <td>
        ![Empty cell](https://mintcdn.com/servicetitan/ieR81UHlP_MquPKe/images/docs/web/components/data-table/cell-empty-light.png?fit=max\&auto=format\&n=ieR81UHlP_MquPKe\&q=85\&s=f6abeefb2fb13db8978784300172fd7f)
      </td>

      <td>Empty cell (used to represent an empty or missing cell value)</td>
      <td>Left</td>
    </tr>

    <tr>
      <td>
        ![Cell with number](https://mintcdn.com/servicetitan/ieR81UHlP_MquPKe/images/docs/web/components/data-table/cell-number-light.png?fit=max\&auto=format\&n=ieR81UHlP_MquPKe\&q=85\&s=14aee78eb7d6dca42df680bfbfd17132)
      </td>

      <td>Cell with number</td>
      <td>Right</td>
    </tr>

    <tr>
      <td>
        ![Cell with percentage](https://mintcdn.com/servicetitan/ieR81UHlP_MquPKe/images/docs/web/components/data-table/cell-percentage-light.png?fit=max\&auto=format\&n=ieR81UHlP_MquPKe\&q=85\&s=f85afb8406b0d700584bc2f40c8144cc)
      </td>

      <td>Cell with percentage</td>
      <td>Right</td>
    </tr>

    <tr>
      <td>
        ![Cell with text](https://mintcdn.com/servicetitan/ieR81UHlP_MquPKe/images/docs/web/components/data-table/cell-text-light.png?fit=max\&auto=format\&n=ieR81UHlP_MquPKe\&q=85\&s=6618d3d4b7874d470f9d1766b88f8d1e)
      </td>

      <td>Cell with text</td>
      <td>Left</td>
    </tr>

    <tr>
      <td>
        ![Cell with time](https://mintcdn.com/servicetitan/ieR81UHlP_MquPKe/images/docs/web/components/data-table/cell-time-light.png?fit=max\&auto=format\&n=ieR81UHlP_MquPKe\&q=85\&s=06d08cc813757407258f8cf04cd9c586)
      </td>

      <td>Cell with time</td>
      <td>Right</td>
    </tr>
  </tbody>
</table>

## Behaviors

### Editing

data-table-editable

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

```tsx lines theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

import { useState } from "react";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";
type Category = "electronics" | "clothing" | "home" | "garden" | "other";

type OrderData = {
  id: string;
  customer_name: string;
  status?: Status[];
  payment_type: PaymentType;
  categories: Category[];
};

const initialData: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    customer_name: "Sophia Rodriguez",
    status: ["completed"],
    payment_type: "credit_card",
    categories: ["electronics", "home"],
  },
  {
    id: "ORD-2024-002",
    customer_name: "TechCorp Solutions",
    status: ["shipped"],
    payment_type: "bank_transfer",
    categories: ["electronics"],
  },
  {
    id: "ORD-2024-003",
    customer_name: "Ahmed Hassan",
    status: ["pending"],
    payment_type: "paypal",
    categories: ["clothing", "other"],
  },
  {
    id: "ORD-2024-004",
    customer_name: "Maria Gonzalez",
    status: ["processing"],
    payment_type: "credit_card",
    categories: ["home", "garden"],
  },
  {
    id: "ORD-2024-005",
    customer_name: "Chen Wei",
    status: ["cancelled"],
    payment_type: "credit_card",
    categories: ["electronics", "clothing"],
  },
  {
    id: "ORD-2024-006",
    customer_name: "Green Valley Farms",
    status: ["completed"],
    payment_type: "check",
    categories: ["garden"],
  },
  {
    id: "ORD-2024-007",
    customer_name: "Jennifer Kim",
    status: ["shipped"],
    payment_type: "cash",
    categories: ["clothing", "home"],
  },
];

function App() {
  const [data, setData] = useState(initialData);

  const createColumn = createColumnHelper<OrderData>();

  const columns = [
    createColumn("id", {
      header: { label: "Order ID" },
      sortable: true,
      resizable: true,
      minWidth: 130,
      editConfig: {
        mode: "text",
        onChange: (value, rowId) => {
          setData((prev) =>
            prev.map((row) =>
              row.id === rowId ? { ...row, id: value as string } : row,
            ),
          );
        },
      },
    }),
    createColumn("customer_name", {
      header: { label: "Customer" },
      sortable: true,
      resizable: true,
      editConfig: {
        mode: "text",
        onChange: (value, rowId) => {
          setData((prev) =>
            prev.map((row) =>
              row.id === rowId
                ? { ...row, customer_name: value as string }
                : row,
            ),
          );
        },
      },
    }),
    createColumn("categories", {
      header: { label: "Categories" },
      minWidth: 180,
      resizable: true,
      renderCell: (value: Category[]) =>
        chipsFormatter(
          value?.map((val: Category) => ({
            label: val.charAt(0).toUpperCase() + val.slice(1),
          })),
          { truncateChips: true },
        ),
      editConfig: {
        mode: "multiselect",
        options: [
          { id: "electronics", label: "Electronics" },
          { id: "clothing", label: "Clothing" },
          { id: "home", label: "Home" },
          { id: "garden", label: "Garden" },
          { id: "other", label: "Other" },
        ],
        onChange: (options, rowId) => {
          const value = options.map((o) => String(o.id)) as Category[];
          setData((prev) =>
            prev.map((row) =>
              row.id === rowId ? { ...row, categories: value } : row,
            ),
          );
        },
      },
    }),
    createColumn("status", {
      header: { label: "Status" },
      resizable: true,
      renderCell: (value: Status[] | undefined) =>
        chipsFormatter(
          value?.map((val: Status) => ({
            label: val.charAt(0).toUpperCase() + val.slice(1),
            color:
              val === "pending"
                ? "#f59e0b"
                : val === "shipped"
                  ? "#8b5cf6"
                  : val === "processing"
                    ? "#3b82f6"
                    : val === "completed"
                      ? "#10b981"
                      : "#ef4444",
          })),
        ),
      sortable: (
        valueA: Status[] | undefined,
        valueB: Status[] | undefined,
      ) => {
        const statusOrder = {
          pending: 1,
          processing: 2,
          shipped: 3,
          completed: 4,
          cancelled: 5,
        } as const;

        const statusA =
          valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;

        const statusB =
          valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

        return statusA - statusB;
      },
      editConfig: {
        mode: "multiselect",
        options: [
          { id: "pending", label: "Pending" },
          { id: "shipped", label: "Shipped" },
          { id: "processing", label: "Processing" },
          { id: "completed", label: "Completed" },
          { id: "cancelled", label: "Cancelled" },
        ],
        onChange: (options, rowId) => {
          const value = options.map((o) => String(o.id)) as Status[];
          setData((prev) =>
            prev.map((row) =>
              row.id === rowId ? { ...row, status: value } : row,
            ),
          );
        },
      },
    }),
    createColumn("payment_type", {
      header: { label: "Payment Type" },
      renderCell: (value: PaymentType) => (
        <span>
          {value === "credit_card"
            ? "Credit Card"
            : value === "cash"
              ? "Cash"
              : value === "bank_transfer"
                ? "Bank Transfer"
                : value === "check"
                  ? "Check"
                  : value === "paypal"
                    ? "PayPal"
                    : value}
        </span>
      ),
      sortable: true,
      minWidth: 180,
      editConfig: {
        mode: "select",
        options: [
          { id: "credit_card", label: "Credit Card" },
          { id: "cash", label: "Cash" },
          { id: "bank_transfer", label: "Bank Transfer" },
          { id: "check", label: "Check" },
          { id: "paypal", label: "PayPal" },
        ],
        onChange: (option, rowId) => {
          if (option) {
            setData((prev) =>
              prev.map((row) =>
                row.id === rowId
                  ? { ...row, payment_type: String(option.id) as PaymentType }
                  : row,
              ),
            );
          }
        },
      },
    }),
  ];

  return <DataTable data={data} columns={columns} />;
}

export default App;
```

Columns can be made editable, which enables edit on hover per cell.

### Row Selection

data-table-controlled-selection

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

```tsx lines theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

import { useState } from "react";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  payment_type: PaymentType;
};

const data: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    order_date: "09/20/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    payment_type: "check",
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    status: ["shipped"],
    order_date: "09/21/2024",
    payment_type: "cash",
  },
];

const createColumn = createColumnHelper<OrderData>();

const statusOrder: Record<Status, number> = {
  pending: 1,
  processing: 2,
  shipped: 3,
  completed: 4,
  cancelled: 5,
};

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
  const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
  const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

  return statusA - statusB;
};

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    sortable: true,
    resizable: true,
    minWidth: 130,
    footerContent: (
      <span>
        <b>Total:</b> {data.length}
      </span>
    ),
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
    footerContent: [
      <span key="total">
        {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
      </span>,
      <span key="average">
        {currencyFormatter(
          data.reduce((acc, row) => acc + row.amount, 0) / data.length,
        )}{" "}
        (avg)
      </span>,
    ],
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
    sortable: sortStatus,
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

function App() {
  const [selectedRowIds, setSelectedRowIds] = useState<
    TableRow<typeof data>[0]["id"][]
  >(["ORD-2024-002", "ORD-2024-002-0"]);

  const handleSelection = (
    newSelectedRowIds: TableRow<typeof data>[0]["id"][],
  ) => {
    setSelectedRowIds(newSelectedRowIds);
  };

  return (
    <DataTable
      data={data}
      columns={columns}
      isSelectable
      onSelectRow={handleSelection}
      selectedRowIds={selectedRowIds}
      defaultExpandedRowIds={["ORD-2024-002"]}
    />
  );
}

export default App;
```

When selection is enabled, a checkbox column appears as the first column. Users can select individual rows or use the header checkbox to select all visible rows.

| Selection State | Appearance |
| - | - |
| Unselected | Empty checkbox |
| Selected | Checked checkbox |
| Indeterminate | Dash in checkbox (headers only, when some rows selected) |

#### Bulk action bar

When a table has bulk actions, a bar replaces the toolbar above the table while rows are selected. The toolbar comes back, with its filters and search kept, once the selection clears. The bar isn't sticky, so it scrolls with the page like the toolbar does.

The bar holds, from left to right:

* **Selected count**, such as "4 selected". Screen readers announce it as it changes.
* **Select all N items**, when the table has more rows than fit on one page. The header checkbox selects only the current page; this button selects every page, including rows that haven't loaded yet. Rows that can't be selected are left out of the count, and the button reads "Select all N items of N" when some are excluded. Sub-rows count as items. Unchecking any row afterwards goes back to a regular selection.
* **Cancel**, which clears the selection and returns focus to the header checkbox.
* **Secondary action**, at most one.
* **Primary action**, a button, or an "Actions" menu when the table has more actions than fit.

After an action finishes, the selection clears. If it fails, the rows stay selected so users can try again.

### Row Expansion

Expand rows to show additional content in two ways:

1. **Sub-rows**: Nested rows with the same column structure as the parent
2. **Sub-components**: Custom content rendered below the parent row

data-table-expanded-rows

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

```tsx lines theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

import { useState } from "react";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  customer_name: string;
  items: number;
  payment_type: PaymentType;
  note?: string;
};

const data: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    payment_type: "credit_card",
    items: 12,
    customer_name: "Sophia Rodriguez",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    payment_type: "bank_transfer",
    items: 45,
    customer_name: "TechCorp Solutions",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    items: 2,
    customer_name: "Ahmed Hassan",
    order_date: "09/20/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    payment_type: "credit_card",
    customer_name: "Maria Gonzalez",
    items: 8,
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    payment_type: "credit_card",
    customer_name: "Chen Wei",
    items: 1,
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    payment_type: "check",
    customer_name: "Green Valley Farms",
    items: 156,
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    customer_name: "Jennifer Kim",
    items: 1,
    status: ["shipped"],
    order_date: "09/21/2024",
    payment_type: "cash",
  },
];

const createColumn = createColumnHelper<OrderData>();

const statusOrder: Record<Status, number> = {
  pending: 1,
  processing: 2,
  shipped: 3,
  completed: 4,
  cancelled: 5,
};

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
  const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
  const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

  return statusA - statusB;
};

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    sortable: true,
    resizable: true,
    minWidth: 130,
    footerContent: (
      <span>
        <b>Total:</b> {data.length}
      </span>
    ),
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
    footerContent: [
      <span key="total">
        {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
      </span>,
      <span key="average">
        {currencyFormatter(
          data.reduce((acc, row) => acc + row.amount, 0) / data.length,
        )}{" "}
        (avg)
      </span>,
    ],
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
    sortable: sortStatus,
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

const expandedRowData: TableRow<OrderData>[] = data.map((row) => ({
  ...row,
  items: row.items * 2,
  subRows: [
    {
      ...row,
      id: `${row.id}-0`,
      items: row.items + 1,
      amount: row.amount / 2,
      status: undefined,
      note: undefined,
    },
    {
      ...row,
      id: `${row.id}-1`,
      items: row.items - 1,
      amount: row.amount / 2,
      status: undefined,
      note: undefined,
    },
  ],
}));

function App() {
  const [selectedRowIds, setSelectedRowIds] = useState<
    TableRow<typeof data>[0]["id"][]
  >(["ORD-2024-002", "ORD-2024-002-0"]);

  const handleSelection = (
    newSelectedRowIds: TableRow<typeof data>[0]["id"][],
  ) => {
    setSelectedRowIds(newSelectedRowIds);
  };

  return (
    <DataTable
      data={expandedRowData}
      columns={columns}
      isSelectable
      onSelectRow={handleSelection}
      selectedRowIds={selectedRowIds}
      defaultExpandedRowIds={["ORD-2024-002"]}
    />
  );
}

export default App;
```

## Visual States

### Header Cell States

![Data table header states](https://mintcdn.com/servicetitan/ieR81UHlP_MquPKe/images/docs/web/components/data-table/header-states.png?fit=max\&auto=format\&n=ieR81UHlP_MquPKe\&q=85\&s=acc9e1637aa7b3953a509bccf11c2b92)

| State | Description |
| - | - |
| Default | Normal header appearance |
| Sorted (ascending/descending) | Indicates active sort with direction indicator |

### Body Cell States

![Default cell](https://mintcdn.com/servicetitan/ieR81UHlP_MquPKe/images/docs/web/components/data-table/default-cell.png?fit=max\&auto=format\&n=ieR81UHlP_MquPKe\&q=85\&s=fae36f0968cc2d9b226b04db24aeaa14)

![Cell focused
state](https://mintcdn.com/servicetitan/ieR81UHlP_MquPKe/images/docs/web/components/data-table/focused-state.png?fit=max\&auto=format\&n=ieR81UHlP_MquPKe\&q=85\&s=b5c7b1d1486bc864ba3dc794e534450e)

![Cell editing
state](https://mintcdn.com/servicetitan/ieR81UHlP_MquPKe/images/docs/web/components/data-table/editing-hover.png?fit=max\&auto=format\&n=ieR81UHlP_MquPKe\&q=85\&s=9ffc77675850dd23f2689dcfdd8fbb00)

![Cell editing active](https://mintcdn.com/servicetitan/ieR81UHlP_MquPKe/images/docs/web/components/data-table/editing-active.png?fit=max\&auto=format\&n=ieR81UHlP_MquPKe\&q=85\&s=0c95e7f898c5793622de2ef587ec990f)

| State | Description |
| - | - |
| Default | Normal cell appearance |
| Focused | Focus ring when cell is keyboard-focused |
| Edit hover | Pencil icon visible to demonstrate an editable cell |
| Editing | Input or dropdown visible for editable cells |

### Error States

DataTable supports two types of error indicators to highlight validation issues:

| Error Type | Description |
| - | - |
| Cell error | Error icon and styling on individual cells with validation issues |
| Row error | Error icon in the first column indicating row-level issues |

Cells with errors display with red text color and a red border. An error icon appears within the cell, and when the error includes a message, hovering over the icon displays a tooltip with the error details.

data-table-cell-errors

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

```tsx lines theme={null}
import {
  DataTable,
  createColumnHelper,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type OrderData = {
  id: string;
  customer_name: string;
  amount: number;
  status: string;
  note: string;
};

const createColumn = createColumnHelper<OrderData>();

// Data with cell-level errors
// Use string error messages to provide context about what's wrong
const data: TableRow<OrderData>[] = [
  {
    id: "ORD-001",
    customer_name: "",
    amount: 2450.75,
    status: "completed",
    note: "Regular order",
    // String error message provides accessible context
    meta: { errors: { customer_name: "Customer name is required" } },
  } as TableRow<OrderData>,
  {
    id: "ORD-002",
    customer_name: "TechCorp Solutions",
    amount: 15750.0,
    status: "shipped",
    note: "Priority shipment",
    // No errors on this row
  },
  {
    id: "ORD-003",
    customer_name: "Ahmed Hassan",
    amount: -50.0,
    status: "pending",
    note: "",
    // Multiple cells can have errors
    meta: {
      errors: {
        amount: "Amount must be positive",
        note: "Note is required for pending orders",
      },
    },
  } as TableRow<OrderData>,
  {
    id: "ORD-004",
    customer_name: "Maria Gonzalez",
    amount: 1200.5,
    status: "processing",
    note: "This is an extremely long note that exceeds the maximum allowed character limit for this field",
    meta: { errors: { note: "Note exceeds 100 character limit" } },
  } as TableRow<OrderData>,
];

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    minWidth: 100,
  }),
  createColumn("customer_name", {
    header: { label: "Customer Name" },
    minWidth: 150,
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    minWidth: 100,
  }),
  createColumn("status", {
    header: { label: "Status" },
    minWidth: 100,
  }),
  createColumn("note", {
    header: { label: "Note" },
    minWidth: 200,
  }),
];

function App() {
  return <DataTable data={data} columns={columns} />;
}

export default App;
```

Row-level errors display an error icon in a dedicated column at the start of the row. This indicates that the entire row has a validation issue, rather than a specific cell.

data-table-row-errors

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

```tsx lines theme={null}
import {
  DataTable,
  createColumnHelper,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type OrderData = {
  id: string;
  customer_name: string;
  amount: number;
  status: string;
};

const createColumn = createColumnHelper<OrderData>();

// Data with row-level errors
// Use string error messages to provide context about what's wrong with the entire row
const data: TableRow<OrderData>[] = [
  {
    id: "ORD-001",
    customer_name: "Sophia Rodriguez",
    amount: 2450.75,
    status: "completed",
    // String error message provides accessible context
    meta: { rowError: "This order has validation issues that need review" },
  } as TableRow<OrderData>,
  {
    id: "ORD-002",
    customer_name: "TechCorp Solutions",
    amount: 15750.0,
    status: "shipped",
    // No error on this row
  },
  {
    id: "ORD-003",
    customer_name: "Ahmed Hassan",
    amount: 89.99,
    status: "pending",
    meta: { rowError: "Missing required shipping information" },
  } as TableRow<OrderData>,
  {
    id: "ORD-004",
    customer_name: "Maria Gonzalez",
    amount: 1200.5,
    status: "processing",
    // No error on this row
  },
  {
    id: "ORD-005",
    customer_name: "Chen Wei",
    amount: 0.0,
    status: "cancelled",
    meta: { rowError: "Order was cancelled due to payment failure" },
  } as TableRow<OrderData>,
];

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    minWidth: 120,
  }),
  createColumn("customer_name", {
    header: { label: "Customer Name" },
    minWidth: 180,
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    minWidth: 100,
  }),
  createColumn("status", {
    header: { label: "Status" },
    minWidth: 100,
  }),
];

function App() {
  return <DataTable data={data} columns={columns} />;
}

export default App;
```

> [!NOTE]
>
> When setting errors, prefer using a descriptive string message over simply
> marking the cell or row as having an error. Messages provide accessible
> context that screen readers announce, helping all users understand why a cell
> or row is in an error state.

### Warning States

DataTable supports two types of warning indicators for non-blocking advisory issues:

| Warning Type | Description |
| - | - |
| Cell warning | Warning icon and styling on individual cells with advisory issues |
| Row warning | Warning icon in the first column indicating row-level advisory issues |

Cells with warnings display with a yellow border and a warning icon. When the warning includes a message, hovering over the icon displays a tooltip with the warning details. Warning states use the same layout as error states but with yellow styling to distinguish non-blocking issues from blocking errors.

data-table-cell-warnings

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

```tsx lines theme={null}
import {
  DataTable,
  createColumnHelper,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type OrderData = {
  id: string;
  customer_name: string;
  amount: number;
  status: string;
  note: string;
};

const createColumn = createColumnHelper<OrderData>();

// Data with cell-level warnings
// Use string warning messages to provide context about advisory issues
const data: TableRow<OrderData>[] = [
  {
    id: "ORD-001",
    customer_name: "Sophia Rodriguez",
    amount: 2450.75,
    status: "completed",
    note: "Regular order",
    // String warning message provides accessible context
    meta: { warnings: { customer_name: "Customer name may be incomplete" } },
  } as TableRow<OrderData>,
  {
    id: "ORD-002",
    customer_name: "TechCorp Solutions",
    amount: 15750.0,
    status: "shipped",
    note: "Priority shipment",
    // No warnings on this row
  },
  {
    id: "ORD-003",
    customer_name: "Ahmed Hassan",
    amount: 89.99,
    status: "pending",
    note: "",
    // Multiple cells can have warnings
    meta: {
      warnings: {
        amount: "Amount is below the typical order minimum",
        note: "Consider adding a note for pending orders",
      },
    },
  } as TableRow<OrderData>,
  {
    id: "ORD-004",
    customer_name: "Maria Gonzalez",
    amount: 1200.5,
    status: "processing",
    note: "This is an extremely long note that may exceed the recommended character limit for this field",
    meta: { warnings: { note: "Note is approaching the 100 character limit" } },
  } as TableRow<OrderData>,
];

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    minWidth: 100,
  }),
  createColumn("customer_name", {
    header: { label: "Customer Name" },
    minWidth: 150,
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    minWidth: 100,
  }),
  createColumn("status", {
    header: { label: "Status" },
    minWidth: 100,
  }),
  createColumn("note", {
    header: { label: "Note" },
    minWidth: 200,
  }),
];

function App() {
  return <DataTable data={data} columns={columns} />;
}

export default App;
```

Rows with warnings display an icon in the first column, similar to row errors but with a warning icon.

data-table-row-warnings

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

```tsx lines theme={null}
import {
  DataTable,
  createColumnHelper,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type OrderData = {
  id: string;
  customer_name: string;
  amount: number;
  status: string;
};

const createColumn = createColumnHelper<OrderData>();

// Data with row-level warnings
// Use string warning messages to provide context about advisory issues for the entire row
const data: TableRow<OrderData>[] = [
  {
    id: "ORD-001",
    customer_name: "Sophia Rodriguez",
    amount: 2450.75,
    status: "completed",
    // String warning message provides accessible context
    meta: { rowWarning: "This order has unusual pricing that may need review" },
  } as TableRow<OrderData>,
  {
    id: "ORD-002",
    customer_name: "TechCorp Solutions",
    amount: 15750.0,
    status: "shipped",
    // No warning on this row
  },
  {
    id: "ORD-003",
    customer_name: "Ahmed Hassan",
    amount: 89.99,
    status: "pending",
    meta: { rowWarning: "Shipping address has not been verified" },
  } as TableRow<OrderData>,
  {
    id: "ORD-004",
    customer_name: "Maria Gonzalez",
    amount: 1200.5,
    status: "processing",
    // No warning on this row
  },
  {
    id: "ORD-005",
    customer_name: "Chen Wei",
    amount: 0.0,
    status: "cancelled",
    meta: {
      rowWarning: "Order was cancelled but payment refund is still pending",
    },
  } as TableRow<OrderData>,
];

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    minWidth: 120,
  }),
  createColumn("customer_name", {
    header: { label: "Customer Name" },
    minWidth: 180,
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    minWidth: 100,
  }),
  createColumn("status", {
    header: { label: "Status" },
    minWidth: 100,
  }),
];

function App() {
  return <DataTable data={data} columns={columns} />;
}

export default App;
```

> [!NOTE]
>
> When both an error and a warning exist on the same cell or row, the error
> takes priority and the warning is not displayed. Errors represent blocking
> issues that must be resolved, while warnings are advisory.

> [!NOTE]
>
> When setting warnings, prefer using a descriptive string message over simply
> marking the cell or row as having a warning. Messages provide accessible
> context that screen readers announce, helping all users understand why a cell
> or row is in a warning state.

## Keyboard Interactions

Users can navigate the DataTable using the following keyboard controls:

| Key | Interaction |
| - | - |
| Tab | Move focus between interactive elements |
| Arrow Up/Down | Navigate between rows when focused on table |
| Arrow Left/Right | Navigate between cells in a row |
| Space | Toggle row selection (when on checkbox) |
| Enter | Activate sort (on header), enter edit mode (on editable cell) |
| F2 | Enter edit mode for editable cells |
| Escape | Exit edit mode, discard changes |

## Accessibility

The DataTable implements accessibility features to ensure all users can interact with tabular data effectively:

* Proper semantic HTML table structure (`table`, `thead`, `tbody`, `tr`, `th`, `td`)
* ARIA attributes for row and column counts
* Sortable headers announce sort state to screen readers
* Selection checkboxes include descriptive labels
* The bulk action bar announces the selected count, and Cancel returns focus to the header checkbox
* Expand/collapse buttons include `aria-expanded` and `aria-controls`
* Loading states announced via `aria-live` regions
* Focus management maintains logical keyboard navigation
* Editable cells announce their editable state

## Usage Guidelines

Use the DataTable for any set of tabular records; do not hand-roll rows with repeated Flex or Layout grids or a bordered grid.

| Standard | Requirement |
| - | - |
| DoSorting | Users need to reorder data by different columns |
| DoPagination | Data sets are too large to display all at once |
| DoRow selection | Users need to select rows for batch operations |
| DoRow expansion | Additional details need to be shown for individual rows |
| DoInline editing | Users need to modify cell values directly |
| DoColumn pinning | Key columns should remain visible during horizontal scroll |
| DoPosition important columns first | Position the most important columns first (left side) where users naturally look; place identifier columns before detail columns; see [Column Organization](#column-organization). |
| DoSupport batch operations clearly | Provide clear feedback about how many items are selected, use the selection state to enable/disable batch action buttons, and consider a "Select All" option; see [Batch Operations](#batch-operations). |
| DoEnable sorting where it adds value | Enable sorting on text, numeric, and date columns where it helps users alphabetize, rank, or order chronologically, and provide default sorting when data has a natural order; see [Sorting](#sorting). |
| DoChoose appropriate page sizes | Use 25 rows as the default, let users change the page size, and match it to typical use cases; see [Choose Appropriate Page Sizes](#choose-appropriate-page-sizes). |
| DoKeep expanded content focused | Show only relevant additional information in expanded areas, keeping it scannable and organized; see [Keep Expanded Content Focused](#keep-expanded-content-focused). |
| Don'tSimple lists | Use [ListView](https://anvil.servicetitan.com/docs/components/list-view/web.md) or [Listbox](https://anvil.servicetitan.com/docs/components/listbox/web.md) for simple selectable lists without tabular structure |
| Don'tHiding critical data behind scroll | Avoid hiding critical data in columns that require horizontal scrolling to view; see [Column Organization](#column-organization). |
| Don'tSelection without action or feedback | Don't enable selection without providing actions that use it, or hide the selection column after users have made selections; see [Batch Operations](#batch-operations). |
| Don'tSorting without value | Don't enable sorting on columns with identical values or complex composite columns where sort order isn't intuitive; see [Sorting](#sorting). |
| Don'tExtreme page sizes | Don't use very small page sizes that require excessive navigation, or very large page sizes that cause performance issues; see [Choose Appropriate Page Sizes](#choose-appropriate-page-sizes). |
| Don'tOverloading expanded rows | Don't put entire page layouts in expanded rows or require users to expand rows to see essential information; see [Keep Expanded Content Focused](#keep-expanded-content-focused). |

### Column Organization

data-table-pinned-columns

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

```tsx lines theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  payment_type: PaymentType;
};

const data: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    order_date: "09/20/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    payment_type: "check",
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    status: ["shipped"],
    order_date: "09/21/2024",
    payment_type: "cash",
  },
];

const createColumn = createColumnHelper<OrderData>();

const statusOrder: Record<Status, number> = {
  pending: 1,
  processing: 2,
  shipped: 3,
  completed: 4,
  cancelled: 5,
};

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
  const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
  const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

  return statusA - statusB;
};

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    sortable: true,
    resizable: true,
    minWidth: 130,
    footerContent: (
      <span>
        <b>Total:</b> {data.length}
      </span>
    ),
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
    footerContent: [
      <span key="total">
        {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
      </span>,
      <span key="average">
        {currencyFormatter(
          data.reduce((acc, row) => acc + row.amount, 0) / data.length,
        )}{" "}
        (avg)
      </span>,
    ],
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
    sortable: sortStatus,
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

function App() {
  return (
    <DataTable
      data={data}
      columns={columns.map((column, index) => ({
        ...column,
        pinned: index === 0 ? "left" : index === 4 ? "right" : undefined,
      }))}
    />
  );
}

export default App;
```

\[!TIP] **Do**
Position the most important columns first (left side) where users naturally look. Place identifier columns (like customer name or ID) before detail columns.

\[!CAUTION] **Don't**
Avoid hiding critical data in columns that require horizontal scrolling to view.

### Batch Operations

data-table-bulk-actions

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

```tsx lines theme={null}
import {
  DataTable,
  createColumnHelper,
  type DataTableBulkSelection,
  type TableRow,
} from "@servicetitan/anvil2/beta";

import { useState } from "react";

type AutomationData = {
  id: string;
  block: string;
  owner: string;
  showAutomation: boolean;
};

const initialData: TableRow<AutomationData>[] = [
  { id: "1", block: "Filters", owner: "Pricebook", showAutomation: false },
  { id: "2", block: "Home Screens", owner: "Projects", showAutomation: true },
  { id: "3", block: "Data List", owner: "Pricebook", showAutomation: true },
  { id: "4", block: "Column Editor", owner: "Schedule", showAutomation: false },
  { id: "5", block: "Feeds", owner: "Workspace", showAutomation: true },
];

const createColumn = createColumnHelper<AutomationData>();

const columns = [
  createColumn("block", { header: { label: "Block" }, minWidth: 160 }),
  createColumn("owner", { header: { label: "Owner" }, minWidth: 140 }),
  createColumn("showAutomation", {
    header: { label: "Show Automation" },
    renderCell: (value: boolean) => (value ? "True" : "False"),
  }),
];

// Singular or plural wording, following the selected count
const automationsLabel = (verb: string) => (count: number) =>
  `${verb} ${count === 1 ? "Automation" : "Automations"}`;

function App() {
  const [data, setData] = useState(initialData);

  const setVisibility =
    (showAutomation: boolean) =>
    (selection: DataTableBulkSelection<AutomationData>) => {
      setData((rows) =>
        rows.map((row) =>
          selection.mode === "all" || selection.rowIds.includes(row.id)
            ? { ...row, showAutomation }
            : row,
        ),
      );
    };

  return (
    <DataTable
      data={data}
      columns={columns}
      isSelectable
      defaultSelectedRowIds={["2", "3"]}
      bulkActions={{
        primary: {
          label: automationsLabel("Show"),
          onClick: setVisibility(true),
        },
        secondary: {
          label: automationsLabel("Hide"),
          onClick: setVisibility(false),
        },
      }}
    />
  );
}

export default App;
```

data-table-controlled-selection

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

```tsx lines theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

import { useState } from "react";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  payment_type: PaymentType;
};

const data: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    order_date: "09/20/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    payment_type: "check",
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    status: ["shipped"],
    order_date: "09/21/2024",
    payment_type: "cash",
  },
];

const createColumn = createColumnHelper<OrderData>();

const statusOrder: Record<Status, number> = {
  pending: 1,
  processing: 2,
  shipped: 3,
  completed: 4,
  cancelled: 5,
};

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
  const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
  const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

  return statusA - statusB;
};

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    sortable: true,
    resizable: true,
    minWidth: 130,
    footerContent: (
      <span>
        <b>Total:</b> {data.length}
      </span>
    ),
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
    footerContent: [
      <span key="total">
        {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
      </span>,
      <span key="average">
        {currencyFormatter(
          data.reduce((acc, row) => acc + row.amount, 0) / data.length,
        )}{" "}
        (avg)
      </span>,
    ],
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
    sortable: sortStatus,
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

function App() {
  const [selectedRowIds, setSelectedRowIds] = useState<
    TableRow<typeof data>[0]["id"][]
  >(["ORD-2024-002", "ORD-2024-002-0"]);

  const handleSelection = (
    newSelectedRowIds: TableRow<typeof data>[0]["id"][],
  ) => {
    setSelectedRowIds(newSelectedRowIds);
  };

  return (
    <DataTable
      data={data}
      columns={columns}
      isSelectable
      onSelectRow={handleSelection}
      selectedRowIds={selectedRowIds}
      defaultExpandedRowIds={["ORD-2024-002"]}
    />
  );
}

export default App;
```

Use row selection when users need to perform batch operations on multiple rows, such as:

* Bulk delete
* Bulk status changes
* Export selected items

Bulk actions run from the built-in bar, so teams don't build their own batch-action buttons. Label actions with the count's grammar in mind, such as "Hide Automation" for one row and "Hide Automations" for several.

Destructive actions, such as delete, use the danger style and ask users to confirm. Your team owns the confirmation dialog: DataTable doesn't render one. The rows stay selected while the dialog is open and clear once users confirm.

> [!TIP]
>
> **Do**

<ul>
  <li>- Use the bulk action bar for actions on selected rows</li>
  <li>- Put the most common action in the primary button</li>
  <li>- Group more than two actions in the primary "Actions" menu</li>
  <li>- Confirm destructive actions in a dialog before applying them</li>
  <li>- Offer "Select all N items" when users often act on every row</li>
  <li>- Provide clear feedback about how many items are selected</li>
  <li>- Use the selection state to enable/disable batch action buttons</li>
  <li>- Consider providing a "Select All" option in the header</li>
</ul>

> [!CAUTION]
>
> **Don't**

<ul>
  <li>- Enable selection without providing actions that use the selection</li>
  <li>- Hide the selection column after users have made selections</li>
  <li>- Build a separate toolbar of batch-action buttons</li>
  <li>- Add more than one secondary action</li>
  <li>- Apply a destructive action without confirmation</li>
</ul>

### Sorting

data-table-sorting

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

```tsx lines theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  payment_type: PaymentType;
};

const data: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    order_date: "09/20/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    payment_type: "check",
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    status: ["shipped"],
    order_date: "09/21/2024",
    payment_type: "cash",
  },
  {
    id: "ORD-2024-008",
    amount: 3780.25,
    status: ["processing"],
    order_date: "09/19/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-009",
    amount: 156.78,
    status: ["completed"],
    order_date: "09/14/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-010",
    amount: 2100.0,
    status: ["shipped", "cancelled"],
    order_date: "09/22/2024",
    payment_type: "credit_card",
  },
];

const createColumn = createColumnHelper<OrderData>();

const statusOrder: Record<Status, number> = {
  pending: 1,
  processing: 2,
  shipped: 3,
  completed: 4,
  cancelled: 5,
};

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
  const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
  const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

  return statusA - statusB;
};

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    sortable: true,
    resizable: true,
    minWidth: 130,
    footerContent: (
      <span>
        <b>Total:</b> {data.length}
      </span>
    ),
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
    footerContent: [
      <span key="total">
        {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
      </span>,
      <span key="average">
        {currencyFormatter(
          data.reduce((acc, row) => acc + row.amount, 0) / data.length,
        )}{" "}
        (avg)
      </span>,
    ],
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
    sortable: sortStatus,
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

function App() {
  return (
    <DataTable
      data={data}
      columns={columns}
      defaultSortedColumn={{ id: "status", desc: false }}
    />
  );
}

export default App;
```

Enable sorting only on columns where it provides value to users.

> [!TIP]
>
> **Do**

<ul>
  <li>- Enable sorting on text columns users might want to alphabetize</li>
  <li>- Enable sorting on numeric columns for ranking or comparison</li>
  <li>- Enable sorting on date columns for chronological ordering</li>
  <li>- Provide default sorting when the data has a natural order</li>
</ul>

> [!CAUTION]
>
> **Don't**

<ul>
  <li>- Enable sorting on columns with identical values</li>
  <li>- Enable sorting on complex composite columns where sort order isn't intuitive</li>
</ul>

### Pagination

#### Choose Appropriate Page Sizes

> [!TIP]
>
> **Do**

<ul>
  <li>- Use 25 rows as the default</li>
  <li>- Enable users to change page size with Pagination</li>
  <li>- Match page size to typical use cases (smaller for quick scans, larger for detailed review)</li>
</ul>

> [!CAUTION]
>
> **Don't**

<ul>
  <li>- Use very small page sizes (like 5 or 10) that require excessive navigation</li>
  <li>- Use very large page sizes that cause performance issues</li>
</ul>

### Row Expansion

data-table-sub-component

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

```tsx lines theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";
import { Card, Text, Button } from "@servicetitan/anvil2";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  customer_name: string;
  items: number;
  payment_type: PaymentType;
  note?: string;
};

const data: OrderData[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    payment_type: "credit_card",
    items: 12,
    customer_name: "Sophia Rodriguez",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    payment_type: "bank_transfer",
    items: 45,
    customer_name: "TechCorp Solutions",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    items: 2,
    customer_name: "Ahmed Hassan",
    order_date: "09/20/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    payment_type: "credit_card",
    customer_name: "Maria Gonzalez",
    items: 8,
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    payment_type: "credit_card",
    customer_name: "Chen Wei",
    items: 1,
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    payment_type: "check",
    customer_name: "Green Valley Farms",
    items: 156,
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    customer_name: "Jennifer Kim",
    items: 1,
    status: ["shipped"],
    order_date: "09/21/2024",
    payment_type: "cash",
  },
  {
    id: "ORD-2024-008",
    amount: 3780.25,
    customer_name: "Roberto Silva",
    items: 23,
    status: ["processing"],
    order_date: "09/19/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-009",
    amount: 156.78,
    customer_name: "Emily Watson",
    items: 4,
    status: ["completed"],
    order_date: "09/14/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-010",
    amount: 2100.0,
    customer_name: "Moonlight Café",
    items: 35,
    status: ["shipped", "cancelled"],
    order_date: "09/22/2024",
    payment_type: "credit_card",
  },
];

const subComponentsData: TableRow<OrderData>[] = data.map((row) => ({
  ...row,
  subComponent: (
    <Card flexDirection="column" gap="2" style={{ width: "500px" }}>
      <Text variant="headline" size="small" el="h2">
        Sub Component
      </Text>
      <Text variant="body">
        Tab press should focus the button. Second Tab press should focus the
        next cell in the table. Shift+Tab press should focus the previous cell
        in the table.
      </Text>
      <Button size="small" appearance="primary">
        Focus Test
      </Button>
    </Card>
  ),
}));

const createColumn = createColumnHelper<OrderData>();

const statusOrder: Record<Status, number> = {
  pending: 1,
  processing: 2,
  shipped: 3,
  completed: 4,
  cancelled: 5,
};

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
  const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
  const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

  return statusA - statusB;
};

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    sortable: true,
    resizable: true,
    minWidth: 130,
    footerContent: (
      <span>
        <b>Total:</b> {data.length}
      </span>
    ),
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
    footerContent: [
      <span key="total">
        {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
      </span>,
      <span key="average">
        {currencyFormatter(
          data.reduce((acc, row) => acc + row.amount, 0) / data.length,
        )}{" "}
        (avg)
      </span>,
    ],
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
    sortable: sortStatus,
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

function App() {
  return (
    <DataTable
      data={subComponentsData}
      columns={columns}
      defaultExpandedRowIds={["ORD-2024-001"]}
    />
  );
}

export default App;
```

#### Sub-Rows vs Sub-Components

| Use Sub-Rows | Use Sub-Components |
| - | - |
| Child data has same structure as parent | Child content differs from table structure |
| Hierarchical data relationships | Detail views, additional context |
| Grouped data (e.g., items in an order) | Custom layouts (tabs, forms, lists) |

#### Keep Expanded Content Focused

> [!TIP]
>
> **Do**

<ul>
  <li>- Show only relevant additional information in expanded areas</li>
  <li>- Keep expanded content scannable and organized</li>
</ul>

> [!CAUTION]
>
> **Don't**

<ul>
  <li>- Put entire page layouts in expanded rows</li>
  <li>- Require users to expand rows to see essential information</li>
</ul>

### Custom Body Cells

When none of the existing body cell types fit a use case, render custom components to body cells.

### Adding Rows

Place an Add row button in a toolbar below the table when users create rows from the table. The button starts the flow and does not add a row itself.

Open a dialog with form fields to collect the new row. A dialog fits fields that don't fit in a table cell, and it is where validation and server checks belong. Close the dialog when the save succeeds so the new row appears in the table.

For a table that can start empty, also show the Add button in the empty state, so the first action is next to the message.

> [!TIP]
>
> **Do**

<ul>
  <li>- Collect the new row in a dialog with form fields</li>
  <li>- Label the button for what it adds, such as "Add request"</li>
  <li>- Show the button in the empty state when the table can start empty</li>
</ul>

> [!CAUTION]
>
> **Don't**

<ul>
  <li>- Make users fill in a long form inside table cells</li>
  <li>- Add a row to the table before it has saved</li>
</ul>

## Alternatives

### DataTable vs ListView

| DataTable | ListView |
| - | - |
| Structured columns with headers | Single-column list of items |
| Built-in sorting by columns | No built-in sorting |
| Pagination for large datasets | Typically shows all items |
| Row selection with checkboxes | Item selection with checkboxes |
| Best for complex data relationships | Best for simple lists of actions or selections |

**Choose DataTable** when data has multiple attributes that benefit from column structure.

**Choose ListView** when displaying a simple list of selectable items.

## Code

> [!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!

> [!WARNING]
>
> **Migrating from the A1 (Kendo) table?**
>
> Don't assume feature parity. The Anvil2 Data Table follows a phased roadmap and doesn't aim to re-implement every legacy Kendo capability.
>
> Before committing, audit the behaviors you depend on, identify gaps, and plan workarounds.

> [!NOTE]
>
> **Known limitations:** Row grouping is not yet supported. Limited default
> formatting and editable cell options are available.

**Implementation**

data-table-playground

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

```tsx lines expandable theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  payment_type: PaymentType;
};

const data: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    order_date: "09/20/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    payment_type: "check",
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    status: ["shipped"],
    order_date: "09/21/2024",
    payment_type: "cash",
  },
];

const createColumn = createColumnHelper<OrderData>();

const statusOrder: Record<Status, number> = {
  pending: 1,
  processing: 2,
  shipped: 3,
  completed: 4,
  cancelled: 5,
};

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
  const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
  const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

  return statusA - statusB;
};

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    sortable: true,
    resizable: true,
    minWidth: 130,
    footerContent: (
      <span>
        <b>Total:</b> {data.length}
      </span>
    ),
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
    footerContent: [
      <span key="total">
        {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
      </span>,
      <span key="average">
        {currencyFormatter(
          data.reduce((acc, row) => acc + row.amount, 0) / data.length,
        )}{" "}
        (avg)
      </span>,
    ],
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
    sortable: sortStatus,
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

function App() {
  return <DataTable data={data} columns={columns} />;
}

export default App;
```

## Common Examples

```tsx theme={null}
import { DataTable, createColumnHelper, currencyFormatter, type TableRow } from "@servicetitan/anvil2/beta";

type OrderData = {
  id: string;
  customer: string;
  amount: number;
  status: "pending" | "shipped" | "completed";
  date: string;
};

const columnHelper = createColumnHelper<OrderData>();

const columns = [
  columnHelper("customer", {
    header: { label: "Customer" },
    sortable: true,
  }),
  columnHelper("amount", {
    header: { label: "Amount" },
    sortable: true,
    renderCell: currencyFormatter,
  }),
  columnHelper("status", {
    header: { label: "Status" },
    sortable: true,
    editConfig: {
      mode: "select",
      options: [
        { id: "pending", label: "Pending" },
        { id: "shipped", label: "Shipped" },
        { id: "completed", label: "Completed" },
      ],
      onChange: (option, rowId) => {
        if (option) {
          console.log(String(option.id), rowId);
        }
      },
    },
  }),
  columnHelper("date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
];

const data: TableRow<OrderData>[] = [
  { id: "1", customer: "Acme Corp", amount: 1250.0, status: "completed", date: "2024-01-15" },
  { id: "2", customer: "TechStart", amount: 890.5, status: "shipped", date: "2024-01-16" },
  // ... more data
];

function ExampleComponent() {
  return <DataTable data={data} columns={columns} />;
}
```

The `DataTable` component provides a comprehensive solution for displaying complex tabular data with built-in support for sorting, pagination, row selection, and expansion.

## Basic Usage

The `DataTable` component requires two props: `data` (row data) and `columns` (column definitions).

```tsx theme={null}
<DataTable data={rowData} columns={columnDefs} />
```

Type safety is built-in using TypeScript generics and a factory function used to create column definitions: `createColumnHelper` (below).

## Working with columns

### Defining data interface

To ensure type safety, a type or interface should be used that defines the columns in the table. The `id` property should always be included, as it is used internally for sorting, selection, etc.

```ts theme={null}
interface TableColumns {
  id: string;
  customer_name: string;
  address: string;
  status: "active" | "inactive";
  amount_due: number;
  paid_percent: number;
}
```

### Defining columns

To create column definitions, use the `createColumnHelper` factory function. This returns a function, which can then be used to create type-safe column definitions.

```ts theme={null}
const createColumn = createColumnHelper<TableColumns>();
```

The `createColumn` function created above accepts two parameters:

1. `id`: `string` | `{ group: string }`. The `string` value must match one of the properties of the type interface provided to the factory function (`id`, `customer_name`, etc.). The object with the `group` parameter can accept any string, and is used to group column headers.
2. `column`: configuration for column (see below for examples)

The `DataTable.columns` prop takes an array of column definitions, each created using the `createColumn` function created above.

> [!NOTE]
>
> `header.label` supports the same limited inline Markdown as [FieldLabel](https://anvil.servicetitan.com/docs/components/field-label/code).

#### Basic columns

data-table-basic-columns

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

```tsx lines expandable theme={null}
import {
  DataTable,
  createColumnHelper,
  currencyFormatter,
  percentFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type CustomerRow = {
  id: string;
  customer_name: string;
  address: string;
  status: string;
  amount_due: number;
  paid_percent: number;
};

const data: TableRow<CustomerRow>[] = [
  {
    id: "CUST-001",
    customer_name: "Jose Ramirez",
    address: "2401 Ontario Street, Cleveland, OH",
    status: "Active",
    amount_due: 1276.43,
    paid_percent: 0.61,
  },
  {
    id: "CUST-002",
    customer_name: "Sophia Rodriguez",
    address: "742 Evergreen Terrace, Springfield, IL",
    status: "Active",
    amount_due: 3450.0,
    paid_percent: 0.85,
  },
  {
    id: "CUST-003",
    customer_name: "TechCorp Solutions",
    address: "100 Innovation Drive, Austin, TX",
    status: "Inactive",
    amount_due: 890.5,
    paid_percent: 1.0,
  },
  {
    id: "CUST-004",
    customer_name: "Ahmed Hassan",
    address: "55 Market Street, San Francisco, CA",
    status: "Active",
    amount_due: 5120.0,
    paid_percent: 0.23,
  },
  {
    id: "CUST-005",
    customer_name: "Green Valley Farms",
    address: "800 Rural Route 3, Des Moines, IA",
    status: "Active",
    amount_due: 2100.75,
    paid_percent: 0.5,
  },
];

const createColumn = createColumnHelper<CustomerRow>();

const columns = [
  createColumn("id", {
    header: {
      label: "Customer ID",
      required: true,
      moreInfo: "Used by support and billing to identify the account.",
    },
    sortable: true,
    resizable: true,
    minWidth: 100,
    maxWidth: 400,
  }),
  createColumn("customer_name", {
    header: { label: "Name" },
    sortable: true,
    resizable: true,
    minWidth: 150,
  }),
  createColumn("address", {
    header: { label: "Address" },
    resizable: true,
    minWidth: 200,
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
  }),
  createColumn("amount_due", {
    header: { label: "Amount Due" },
    renderCell: (value: CustomerRow["amount_due"]) => currencyFormatter(value),
    minWidth: 100,
    maxWidth: 100,
  }),
  createColumn("paid_percent", {
    header: { label: "Paid %" },
    renderCell: (value: CustomerRow["paid_percent"]) => percentFormatter(value),
    minWidth: 100,
    maxWidth: 100,
  }),
];

function App() {
  return <DataTable data={data} columns={columns} />;
}

export default App;
```

#### Grouped columns

data-table-grouped-columns

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

```tsx lines expandable theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  payment_type: PaymentType;
};

const data: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    order_date: "09/20/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    payment_type: "check",
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    status: ["shipped"],
    order_date: "09/21/2024",
    payment_type: "cash",
  },
];

const createColumn = createColumnHelper<OrderData>();

const statusOrder: Record<Status, number> = {
  pending: 1,
  processing: 2,
  shipped: 3,
  completed: 4,
  cancelled: 5,
};

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
  const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
  const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

  return statusA - statusB;
};

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

// Define individual columns that will be grouped
const orderInfoColumns = [
  createColumn("id", {
    header: {
      label: "Order ID",
      required: true,
      moreInfo: "Unique order identifier used in downstream workflows.",
    },
    sortable: true,
    resizable: true,
    minWidth: 130,
    footerContent: (
      <span>
        <b>Total:</b> {data.length}
      </span>
    ),
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
    sortable: sortStatus,
  }),
];

const paymentColumns = [
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
    footerContent: [
      <span key="total">
        {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
      </span>,
      <span key="average">
        {currencyFormatter(
          data.reduce((acc, row) => acc + row.amount, 0) / data.length,
        )}{" "}
        (avg)
      </span>,
    ],
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

// Create grouped columns using the group syntax
const groupedColumns = [
  createColumn(
    { group: "order_info" },
    {
      header: {
        label: "Order Info",
        required: true,
        moreInfo: "Columns describing the order record.",
      },
      columns: orderInfoColumns,
    },
  ),
  createColumn(
    { group: "payment_details" },
    {
      header: { label: "Payment Details" },
      columns: paymentColumns,
    },
  ),
];

function App() {
  return <DataTable data={data} columns={groupedColumns} />;
}

export default App;
```

#### Pinned columns

Columns can be pinned to the left or right side of the table using the `pinned` property in the column options. Pinned columns have sticky position when the table is scrolled horizontally.

data-table-pinned-columns

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

```tsx lines expandable theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  payment_type: PaymentType;
};

const data: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    order_date: "09/20/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    payment_type: "check",
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    status: ["shipped"],
    order_date: "09/21/2024",
    payment_type: "cash",
  },
];

const createColumn = createColumnHelper<OrderData>();

const statusOrder: Record<Status, number> = {
  pending: 1,
  processing: 2,
  shipped: 3,
  completed: 4,
  cancelled: 5,
};

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
  const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
  const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

  return statusA - statusB;
};

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    sortable: true,
    resizable: true,
    minWidth: 130,
    footerContent: (
      <span>
        <b>Total:</b> {data.length}
      </span>
    ),
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
    footerContent: [
      <span key="total">
        {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
      </span>,
      <span key="average">
        {currencyFormatter(
          data.reduce((acc, row) => acc + row.amount, 0) / data.length,
        )}{" "}
        (avg)
      </span>,
    ],
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
    sortable: sortStatus,
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

function App() {
  return (
    <DataTable
      data={data}
      columns={columns.map((column, index) => ({
        ...column,
        pinned: index === 0 ? "left" : index === 4 ? "right" : undefined,
      }))}
    />
  );
}

export default App;
```

> [!WARNING]
>
> Grouped columns cannot be pinned.

### Sorting columns

If the `sortable` property is used in a column definition, it will attempt to sort the rows of the table when the column header is clicked.

#### Default sorting (`boolean`)

If the `sortable` property is set to `true`, clicking on the column header will attempt to sort the rows alphabetically based on the column data—first ascending, then descending, then unsorted. The sorting applies to the text or numeric value of the cell, regardless of what is rendered by the `renderCell` function, if provided.

```ts theme={null}
createColumn("customer_name", {
  header: { label: "Name" },
  sortable: true,
}),
```

#### Custom sorting (function)

In some cases, especially with array values, it may be easier to control the sorting logic for a column. To do this, pass a function to the `sortable` property in the column definition:

```ts theme={null}
createColumn("customer_name", {
  header: { label: "Name" },
  // sortable: (a, b) => number
  sortable: (nameA, nameB) => {
    // sort by last name
    const nameAWords = nameA.split(/\s+/);
    const lastNameA = nameAWords[nameAWords.length - 1];

    const nameBWords = nameB.split(/\s+/);
    const lastNameB = nameBWords[nameBWords.length - 1];

    return lastNameA.localeCompare(lastNameB);
  },
}),
```

#### Controlling sort (props)

##### Default sorted column

A column can be sorted by default using the `DataTable.defaultSortedColumn` prop. The `onSort` callback is called when the user clicks on a sortable column header cell, and passes the column `id` and whether the column is sorted descending. If the new sort state is unsorted, it will pass `undefined` instead.

data-table-sorting

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

```tsx lines expandable theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  payment_type: PaymentType;
};

const data: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    order_date: "09/20/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    payment_type: "check",
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    status: ["shipped"],
    order_date: "09/21/2024",
    payment_type: "cash",
  },
  {
    id: "ORD-2024-008",
    amount: 3780.25,
    status: ["processing"],
    order_date: "09/19/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-009",
    amount: 156.78,
    status: ["completed"],
    order_date: "09/14/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-010",
    amount: 2100.0,
    status: ["shipped", "cancelled"],
    order_date: "09/22/2024",
    payment_type: "credit_card",
  },
];

const createColumn = createColumnHelper<OrderData>();

const statusOrder: Record<Status, number> = {
  pending: 1,
  processing: 2,
  shipped: 3,
  completed: 4,
  cancelled: 5,
};

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
  const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
  const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

  return statusA - statusB;
};

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    sortable: true,
    resizable: true,
    minWidth: 130,
    footerContent: (
      <span>
        <b>Total:</b> {data.length}
      </span>
    ),
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
    footerContent: [
      <span key="total">
        {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
      </span>,
      <span key="average">
        {currencyFormatter(
          data.reduce((acc, row) => acc + row.amount, 0) / data.length,
        )}{" "}
        (avg)
      </span>,
    ],
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
    sortable: sortStatus,
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

function App() {
  return (
    <DataTable
      data={data}
      columns={columns}
      defaultSortedColumn={{ id: "status", desc: false }}
    />
  );
}

export default App;
```

##### Controlled sort state

It is also possible to fully control the sorting state using the `sortedColumn` and `onSort` props of the `DataTable`.

```tsx theme={null}
import { useState } from "react";

type AutomationData = {
import { DataTable, SortedColumn } from "@servicetitan/anvil2/beta";

export const SortedDataTable () => {
  const [sortedColumn, setSortedColumn] = useState<SortedColumn | undefined>({
    id: "customer_name",
    desc: false,
  });

  return (
    <DataTable
      data={data}
      columns={primaryColumns}
      onSort={setSortedColumn}
      sortedColumn={sortedColumn}
    />
  );
};
```

## Working with cells

### Formatting data in cells

The `renderCell` property in the `createColumn` options parameter enables custom rendering of cell data. If no `renderCell` function is provided, the cell will attempt to render the current value as plain text. When your read view returns JSX or multiline content but default sorting should still use plain text, add `getCellText` for the simple case, or `getReadRenderResult` when you want one hook to return both rendered content and raw sortable text.

Anvil2 provides utility functions for common render patterns. In most cases, **these formatters should be used**, but there may be edge cases that aren't supported.

#### Default formatters

Currently, the following formatter functions are available:

* `booleanFormatter`: show boolean as customizable text (e.g., "True"/"False", "Yes"/"No", "On"/"Off")
* `chipsFormatter`: show chips in cell, with optional truncation
* `currencyFormatter`: show number as currency, with i18n options
* `dateFormatter`: show date with i18n options (accepts ISO string)
* `dateTimeFormatter`: show date and time with i18n options (accepts ISO string or Date object)
* `numberFormatter`: show number with i18n options, grouping separators, and notation styles
* `percentFormatter`: show number as percentage, with i18n options
* `timeFormatter`: show time with i18n options (accepts ISO time string)
* `yearlessDateFormatter`: show month and day without year (accepts Temporal PlainMonthDay string or YearlessDate object)

> [!NOTE]
>
> In the future, we plan to have a robust set of formatters available for common cell formats to avoid inconsistencies. Feel free to contribute formatters that cover common use cases, and we would be happy to review! For complex or unproven use cases, we may suggest adding it to the `anvil2-ext-common` library instead. [Learn more about this extended library here](https://github.com/servicetitan/hammer/tree/main/packages/ext-common/README.md).

Import these functions from Anvil2 and apply them when defining the columns:

data-table-formatters

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

```tsx lines expandable theme={null}
import {
  DataTable,
  createColumnHelper,
  booleanFormatter,
  currencyFormatter,
  dateFormatter,
  numberFormatter,
  percentFormatter,
  timeFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type ProductData = {
  id: string;
  is_active: boolean;
  quantity: number;
  amount_due: number;
  paid_percent: number;
  order_date: string;
  start_time: string;
};

const data: TableRow<ProductData>[] = [
  {
    id: "PRD-001",
    is_active: true,
    quantity: 1250,
    amount_due: 2450.75,
    paid_percent: 0.85,
    order_date: "2024-09-15",
    start_time: "08:30:00",
  },
  {
    id: "PRD-002",
    is_active: false,
    quantity: 340,
    amount_due: 15750.0,
    paid_percent: 0.42,
    order_date: "2024-09-18",
    start_time: "14:15:00",
  },
  {
    id: "PRD-003",
    is_active: true,
    quantity: 89,
    amount_due: 89.99,
    paid_percent: 1.0,
    order_date: "2024-09-20",
    start_time: "09:00:00",
  },
  {
    id: "PRD-004",
    is_active: true,
    quantity: 5600,
    amount_due: 1200.5,
    paid_percent: 0.0,
    order_date: "2024-09-12",
    start_time: "17:45:00",
  },
];

const createColumn = createColumnHelper<ProductData>();

const columns = [
  createColumn("id", {
    header: { label: "Product ID" },
    minWidth: 100,
  }),
  createColumn("is_active", {
    header: { label: "Active" },
    renderCell: (value: ProductData["is_active"]) => booleanFormatter(value),
    minWidth: 80,
  }),
  createColumn("quantity", {
    header: { label: "Quantity" },
    renderCell: (value: ProductData["quantity"]) => numberFormatter(value),
    minWidth: 100,
  }),
  createColumn("amount_due", {
    header: { label: "Amount Due" },
    renderCell: (value: ProductData["amount_due"]) => currencyFormatter(value),
    minWidth: 120,
  }),
  createColumn("paid_percent", {
    header: { label: "Paid %" },
    renderCell: (value: ProductData["paid_percent"]) => percentFormatter(value),
    minWidth: 80,
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    renderCell: (value: string) => dateFormatter(value),
    minWidth: 130,
  }),
  createColumn("start_time", {
    header: { label: "Start Time" },
    renderCell: (value: string) => timeFormatter(value),
    minWidth: 110,
  }),
];

function App() {
  return <DataTable data={data} columns={columns} />;
}

export default App;
```

To change the internationalization options for the `currencyFormatter` and `percentFormatter`, pass an object as the second parameter of the functions:

```tsx theme={null}
createColumn("amount_due", {
  header: { label: "Amount Due" },
  renderCell: (value) => currencyFormatter(value, {
    locale: "en-AU",
    currency: "AUD",
  }),
}),
createColumn("paid_percent", {
  header: { label: "Paid %" },
  renderCell: (value) => percentFormatter(value, {
    locale: "en-AU",
  }),
}),
```

#### Boolean formatter

The `booleanFormatter` displays `true` as "True" and `false` as "False" by default. Customize the labels for i18n or different use cases:

```tsx theme={null}
// Default: "True" / "False"
createColumn("is_active", {
  header: { label: "Active" },
  renderCell: booleanFormatter,
}),

// Custom labels: "Yes" / "No"
createColumn("is_enabled", {
  header: { label: "Enabled" },
  renderCell: (value) => booleanFormatter(value, {
    trueLabel: "Yes",
    falseLabel: "No",
  }),
}),

// Custom labels: "On" / "Off"
createColumn("is_on", {
  header: { label: "Status" },
  renderCell: (value) => booleanFormatter(value, {
    trueLabel: "On",
    falseLabel: "Off",
  }),
}),
```

#### Number formatter

The `numberFormatter` provides flexible number formatting with full `Intl.NumberFormat` support:

```tsx theme={null}
// Default formatting with grouping separators
createColumn("quantity", {
  header: { label: "Quantity" },
  renderCell: numberFormatter,
}),

// Compact notation for large numbers (e.g., 1.2M, 45K)
createColumn("views", {
  header: { label: "Views" },
  renderCell: (value) => numberFormatter(value, {
    notation: "compact",
  }),
}),

// Scientific notation
createColumn("measurement", {
  header: { label: "Measurement" },
  renderCell: (value) => numberFormatter(value, {
    notation: "scientific",
  }),
}),

// Always show sign (+ or -)
createColumn("change", {
  header: { label: "Change" },
  renderCell: (value) => numberFormatter(value, {
    signDisplay: "always",
  }),
}),

// Fixed decimal places
createColumn("rating", {
  header: { label: "Rating" },
  renderCell: (value) => numberFormatter(value, {
    minimumFractionDigits: 1,
    maximumFractionDigits: 1,
  }),
}),
```

#### Date formatter

The `dateFormatter` displays plain dates with locale-aware formatting. It accepts ISO date strings in `"YYYY-MM-DD"` format (like Temporal.PlainDate).

For JavaScript `Date` objects or date-times with timezone information, use `dateTimeFormatter` instead.

```tsx theme={null}
// Default format: "medium" (Jan 15, 2024)
createColumn("order_date", {
  header: { label: "Order Date" },
  renderCell: dateFormatter,
}),

// Short format (01/15/2024)
createColumn("created_at", {
  header: { label: "Created" },
  renderCell: (value) => dateFormatter(value, { format: "short" }),
}),

// Long format (January 15, 2024)
createColumn("due_date", {
  header: { label: "Due Date" },
  renderCell: (value) => dateFormatter(value, { format: "long" }),
}),

// Full format (Monday, January 15, 2024)
createColumn("event_date", {
  header: { label: "Event Date" },
  renderCell: (value) => dateFormatter(value, { format: "full" }),
}),

// Custom format using Intl.DateTimeFormatOptions
createColumn("custom_date", {
  header: { label: "Custom" },
  renderCell: (value) => dateFormatter(value, {
    format: { weekday: "short", month: "short", day: "numeric" },
  }),
}),
```

#### Time formatter

The `timeFormatter` displays times with locale-aware formatting. It accepts ISO time strings (`"HH:mm:ss"` or `"HH:mm"`):

```tsx theme={null}
// Default format: "short" (08:30 AM)
createColumn("start_time", {
  header: { label: "Start Time" },
  renderCell: timeFormatter,
}),

// Medium format with seconds (08:30:00 AM)
createColumn("timestamp", {
  header: { label: "Timestamp" },
  renderCell: (value) => timeFormatter(value, { format: "medium" }),
}),

// Custom 24-hour format using Intl.DateTimeFormatOptions
createColumn("military_time", {
  header: { label: "Time (24h)" },
  renderCell: (value) => timeFormatter(value, {
    format: { hour: "2-digit", minute: "2-digit", hour12: false },
  }),
}),
```

#### Date-time formatter

The `dateTimeFormatter` displays both date and time together. It accepts ISO date-time strings (`"YYYY-MM-DDTHH:mm:ss"`) or JavaScript `Date` objects.

**Timezone handling:**

* Strings without timezone info are treated as "plain date-times" - no timezone conversion or display occurs
* Strings with timezone info honor the provided timezone and display the timezone abbreviation (e.g., "PST", "UTC")
* `Date` objects always display their timezone: local timezone by default, or use the `timeZone` option to specify an IANA timezone (e.g., `"America/New_York"`, `"Europe/London"`, `"UTC"`)

```tsx theme={null}
// Plain date-time string (no timezone info)
// "2024-01-15T09:30:00" → "Jan 15, 2024, 09:30 AM"
createColumn("created_at", {
  header: { label: "Created At" },
  renderCell: dateTimeFormatter,
}),

// String with UTC timezone
// "2024-01-15T09:30:00Z" → "Jan 15, 2024, 09:30 AM UTC"
createColumn("utc_timestamp", {
  header: { label: "UTC Timestamp" },
  renderCell: dateTimeFormatter,
}),

// String with timezone offset
// "2024-01-15T09:30:00-05:00" → "Jan 15, 2024, 09:30 AM EST"
createColumn("eastern_time", {
  header: { label: "Eastern Time" },
  renderCell: dateTimeFormatter,
}),

// Date object with specific timezone
createColumn("event_time", {
  header: { label: "Event (Tokyo)" },
  renderCell: (value) => dateTimeFormatter(value, {
    timeZone: "Asia/Tokyo",
  }),
}),

// Short date + short time
createColumn("modified_at", {
  header: { label: "Modified" },
  renderCell: (value) => dateTimeFormatter(value, {
    dateFormat: "short",
    timeFormat: "short",
  }),
}),

// Long date + medium time
createColumn("scheduled_at", {
  header: { label: "Scheduled" },
  renderCell: (value) => dateTimeFormatter(value, {
    dateFormat: "long",
    timeFormat: "medium",
  }),
}),

```

#### Yearless date formatter

The `yearlessDateFormatter` displays month and day without the year. It accepts:

* Temporal PlainMonthDay string format (`"--MM-DD"`)
* `YearlessDate` object (`{ month: number, day: number }`) from Anvil2's `DateFieldYearless` component

```tsx theme={null}
// Using string format (Temporal PlainMonthDay)
// Default format: "long" (January 15)
createColumn("birthday", {
  header: { label: "Birthday" },
  renderCell: yearlessDateFormatter,
}),

// Short format (Jan 05)
createColumn("anniversary", {
  header: { label: "Anniversary" },
  renderCell: (value) => yearlessDateFormatter(value, { format: "short" }),
}),

// Custom format using Intl.DateTimeFormatOptions
createColumn("recurring_date", {
  header: { label: "Recurring" },
  renderCell: (value) => yearlessDateFormatter(value, {
    format: { month: "2-digit", day: "2-digit" },
  }),
}),
```

When using with `DateFieldYearless` component values (which return `YearlessDate` objects):

```tsx theme={null}
// YearlessDate object format: { month: 7, day: 4 } → "July 4"
createColumn("holiday", {
  header: { label: "Holiday" },
  renderCell: (value) => yearlessDateFormatter(value),
}),
```

Other options are available for specific formatters:

| **Formatter** | **Options** |
| - | - |
| `booleanFormatter` | `trueLabel: string`, `falseLabel: string` |
| `chipsFormatter` | `truncateChips: boolean` |
| `currencyFormatter` | `locale: string`, `currency: string` |
| `dateFormatter` | `locale: string`, `format: "short" \| "medium" \| "long" \| "full" \| Intl.DateTimeFormatOptions` |
| `dateTimeFormatter` | `locale: string`, `dateFormat: "short" \| "medium" \| "long" \| "full" \| Intl.DateTimeFormatOptions`, `timeFormat: "short" \| "medium" \| "none" \| Intl.DateTimeFormatOptions`, `timeZone: IanaZone` |
| `numberFormatter` | `locale: string`, `minimumFractionDigits: number`, `maximumFractionDigits: number`, `useGrouping: boolean`, `notation: "standard" \| "scientific" \| "engineering" \| "compact"`, `signDisplay: "auto" \| "never" \| "always" \| "exceptZero"` |
| `percentFormatter` | `locale: string`, `decimals: number` |
| `timeFormatter` | `locale: string`, `format: "short" \| "medium" \| Intl.DateTimeFormatOptions` |
| `yearlessDateFormatter` | `locale: string`, `format: "short" \| "long" \| Intl.DateTimeFormatOptions` |

#### Chips formatter

The `chipsFormatter` works for both `string` and `string[]` values. The `value` parameter passed from the `renderCell` option will use the type based on the column it maps to in the interface used in the `createColumnHelper` function. In our example, since `status` has type `"active" | "inactive`, it will assume a `string[]`, but in other cases it can be used with a single `string`.

The `chipsFormatter` can be customized in two ways:

1. Set the `truncateChips` option to `true` to automatically truncate chips when they overflow the cell boundaries.
2. Map the `value` from the `renderCell` to an object that includes `ChipProps` (for adjusting the color, icon, etc.).

```tsx theme={null}
createColumn("status", {
  header: { label: "Status" },
  renderCell: (value) =>
    chipsFormatter(
      value?.map((status) => ({
        label: status.charAt(0).toUpperCase() + status.slice(1), // capitalize
        color: status === "active" ? "#007A4D" : "#e13212", // change chip color
      })),
      {
        truncateChips: true,
      }
    ),
}),
```

#### Custom formats

To customize the format of a cell in a way that isn't supported by the default formatters, use the `renderCell` property, and return a `ReactNode`.

```tsx theme={null}
createColumn("status", {
  header: { label: "Status" },
  renderCell: (value) => value.map((status) => (
    <Text variant="body" size="small" subdued={status === "inactive"}>
      {status === "active" ? "Active" : "Inactive" }
    </Text>
  )),
}),
```

> [!WARNING]
>
> We cannot guarantee accessibility or proper keyboard navigation for custom rendered cells, so it is up to the implementor to build with caution and test screen readers and keyboard navigation.

### Overflow surface

Read-only leaf columns can opt into an overflow disclosure surface. Use `overflow: { mode: "surface" }` when the mounted cell content may be clipped by a constrained column width or height and users need access to the full read-only value.

```tsx theme={null}
createColumn("description", {
  header: { label: "Description" },
  defaultWidth: 220,
  overflow: { mode: "surface" },
}),
```

When the cell content is clipped, DataTable shows a trailing affordance on hover or focus. Users can open the Surface by clicking the cell, or by focusing the cell and pressing `Enter`, `Space`, or `F2`. Short cells that are not clipped do not show the affordance.

> [!NOTE]
>
> `overflow` is only available for read-only leaf columns. It is not available on grouped columns or columns with `editConfig`.

### Editable cells

The `editConfig` property can be used when defining columns to make data table cells editable. The primary modes are `"text"`, `"number"`, `"boolean"`, `"select"`, `"multiselect"`, and `"custom"`.

Custom edit mode is for object-backed cells that keep a formatted read view while opening a custom multi-field editor surface.

* `renderCell` stays on the read path.
* `getCellText` is the preferred way to supply sortable plain text when the read UI is rich or multiline.
* `getReadRenderResult` remains available for advanced cases where one hook should return both rendered read content and its sortable raw string.
* `renderEditor` receives a typed `controller` with draft state, validation, focus, and close helpers.
* `onCommit` persists the final committed object value, while `onDraftUpdate` remains available for per-keystroke draft updates.
* Surface title, width, height, and close-button copy live under `editConfig.surface`.
* If you want Enter-to-submit behavior in a custom editor, wrap the editable contents in a `<form onSubmit={controller.handleSubmit}>`; Enter only submits through that form contract.
* Validation remains informational by default; set `blockOnValidationError: true` if submit-style close requests should stay open while validation errors exist.

For the full custom-mode guide, see [Custom Edit Mode](https://anvil.servicetitan.com/docs/components/data-table/beta-changes/custom-edit-mode.md).

The appearance of the cell content still respects the read configuration. Each mode behaves differently when editing:

* The **text** cell changes to a text input.
* The **number** cell changes to a formatted number input with keyboard increment/decrement support.
* The **boolean** cell opens a constrained true/false selector.
* The **select** cell triggers a menu dropdown.
* The **multiselect** cell triggers a popover with a search field and list view.
* The **custom editable cell** opens an anchored custom editor surface from `renderEditor`.

Editable cells open when the user clicks a cell, or focuses it and presses "Enter", "F2", or "Space". Simple modes commit through their normal close behavior, such as blur, selection, or confirming the inline field. In custom mode, submit-style close requests commit the current draft, while `Escape` discards it and returns focus to the originating cell. Enter submits a custom editor only when the focused control lives inside a form wired with `onSubmit={controller.handleSubmit}`. Validation errors stay informational unless `blockOnValidationError` is set to `true`.

> [!NOTE]
>
> In `renderEditor`, wrap custom editor contents in a `<form onSubmit={controller.handleSubmit}>` if you want Enter to submit the draft.

#### Select All in async multiselect cells

Async multiselect edit cells forward `editConfig.selectAll` to the underlying [MultiSelectMenu](https://anvil.servicetitan.com/docs/components/multi-select-menu/code#select-all). Because `editConfig` is defined once per column and shared by every cell, a static `checkState` cannot reflect an individual cell's selection. Pass a function for `checkState` to derive the Select All state from each cell's current selection:

```tsx theme={null}
editConfig: {
  mode: "multiselect-async",
  confirmation: true,
  loadOptions,
  onChange,
  selectAll: {
    // checkState receives the cell's current selection.
    checkState: (current) =>
      current.length === 0
        ? "unchecked"
        : current.length >= allOptions.length
          ? "checked"
          : "indeterminate",
    compute: (current) =>
      current.length >= allOptions.length ? [] : allOptions,
  },
}
```

data-table-editable

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

```tsx lines expandable theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

import { useState } from "react";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";
type Category = "electronics" | "clothing" | "home" | "garden" | "other";

type OrderData = {
  id: string;
  customer_name: string;
  status?: Status[];
  payment_type: PaymentType;
  categories: Category[];
};

const initialData: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    customer_name: "Sophia Rodriguez",
    status: ["completed"],
    payment_type: "credit_card",
    categories: ["electronics", "home"],
  },
  {
    id: "ORD-2024-002",
    customer_name: "TechCorp Solutions",
    status: ["shipped"],
    payment_type: "bank_transfer",
    categories: ["electronics"],
  },
  {
    id: "ORD-2024-003",
    customer_name: "Ahmed Hassan",
    status: ["pending"],
    payment_type: "paypal",
    categories: ["clothing", "other"],
  },
  {
    id: "ORD-2024-004",
    customer_name: "Maria Gonzalez",
    status: ["processing"],
    payment_type: "credit_card",
    categories: ["home", "garden"],
  },
  {
    id: "ORD-2024-005",
    customer_name: "Chen Wei",
    status: ["cancelled"],
    payment_type: "credit_card",
    categories: ["electronics", "clothing"],
  },
  {
    id: "ORD-2024-006",
    customer_name: "Green Valley Farms",
    status: ["completed"],
    payment_type: "check",
    categories: ["garden"],
  },
  {
    id: "ORD-2024-007",
    customer_name: "Jennifer Kim",
    status: ["shipped"],
    payment_type: "cash",
    categories: ["clothing", "home"],
  },
];

function App() {
  const [data, setData] = useState(initialData);

  const createColumn = createColumnHelper<OrderData>();

  const columns = [
    createColumn("id", {
      header: { label: "Order ID" },
      sortable: true,
      resizable: true,
      minWidth: 130,
      editConfig: {
        mode: "text",
        onChange: (value, rowId) => {
          setData((prev) =>
            prev.map((row) =>
              row.id === rowId ? { ...row, id: value as string } : row,
            ),
          );
        },
      },
    }),
    createColumn("customer_name", {
      header: { label: "Customer" },
      sortable: true,
      resizable: true,
      editConfig: {
        mode: "text",
        onChange: (value, rowId) => {
          setData((prev) =>
            prev.map((row) =>
              row.id === rowId
                ? { ...row, customer_name: value as string }
                : row,
            ),
          );
        },
      },
    }),
    createColumn("categories", {
      header: { label: "Categories" },
      minWidth: 180,
      resizable: true,
      renderCell: (value: Category[]) =>
        chipsFormatter(
          value?.map((val: Category) => ({
            label: val.charAt(0).toUpperCase() + val.slice(1),
          })),
          { truncateChips: true },
        ),
      editConfig: {
        mode: "multiselect",
        options: [
          { id: "electronics", label: "Electronics" },
          { id: "clothing", label: "Clothing" },
          { id: "home", label: "Home" },
          { id: "garden", label: "Garden" },
          { id: "other", label: "Other" },
        ],
        onChange: (options, rowId) => {
          const value = options.map((o) => String(o.id)) as Category[];
          setData((prev) =>
            prev.map((row) =>
              row.id === rowId ? { ...row, categories: value } : row,
            ),
          );
        },
      },
    }),
    createColumn("status", {
      header: { label: "Status" },
      resizable: true,
      renderCell: (value: Status[] | undefined) =>
        chipsFormatter(
          value?.map((val: Status) => ({
            label: val.charAt(0).toUpperCase() + val.slice(1),
            color:
              val === "pending"
                ? "#f59e0b"
                : val === "shipped"
                  ? "#8b5cf6"
                  : val === "processing"
                    ? "#3b82f6"
                    : val === "completed"
                      ? "#10b981"
                      : "#ef4444",
          })),
        ),
      sortable: (
        valueA: Status[] | undefined,
        valueB: Status[] | undefined,
      ) => {
        const statusOrder = {
          pending: 1,
          processing: 2,
          shipped: 3,
          completed: 4,
          cancelled: 5,
        } as const;

        const statusA =
          valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;

        const statusB =
          valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

        return statusA - statusB;
      },
      editConfig: {
        mode: "multiselect",
        options: [
          { id: "pending", label: "Pending" },
          { id: "shipped", label: "Shipped" },
          { id: "processing", label: "Processing" },
          { id: "completed", label: "Completed" },
          { id: "cancelled", label: "Cancelled" },
        ],
        onChange: (options, rowId) => {
          const value = options.map((o) => String(o.id)) as Status[];
          setData((prev) =>
            prev.map((row) =>
              row.id === rowId ? { ...row, status: value } : row,
            ),
          );
        },
      },
    }),
    createColumn("payment_type", {
      header: { label: "Payment Type" },
      renderCell: (value: PaymentType) => (
        <span>
          {value === "credit_card"
            ? "Credit Card"
            : value === "cash"
              ? "Cash"
              : value === "bank_transfer"
                ? "Bank Transfer"
                : value === "check"
                  ? "Check"
                  : value === "paypal"
                    ? "PayPal"
                    : value}
        </span>
      ),
      sortable: true,
      minWidth: 180,
      editConfig: {
        mode: "select",
        options: [
          { id: "credit_card", label: "Credit Card" },
          { id: "cash", label: "Cash" },
          { id: "bank_transfer", label: "Bank Transfer" },
          { id: "check", label: "Check" },
          { id: "paypal", label: "PayPal" },
        ],
        onChange: (option, rowId) => {
          if (option) {
            setData((prev) =>
              prev.map((row) =>
                row.id === rowId
                  ? { ...row, payment_type: String(option.id) as PaymentType }
                  : row,
              ),
            );
          }
        },
      },
    }),
  ];

  return <DataTable data={data} columns={columns} />;
}

export default App;
```

#### Custom edit mode

Use custom mode when a cell's committed value is an object and the read view should stay separate from the edit UI. This example keeps an address object intact, formats the read state as multiline text, uses `validateDraft` plus `onRequestClose` to keep invalid submit-style closes open, and commits the full object through `editConfig.onChange`. Because the editor is wrapped in `<form onSubmit={controller.handleSubmit}>`, pressing Enter inside a single-line field submits through that form.

```tsx theme={null}
import { Button } from "@servicetitan/anvil2";
import {
  DataTable,
  Flex,
  TextField,
  Textarea,
  createColumnHelper,
  type TableRow,
} from "@servicetitan/anvil2/beta";
import { useState } from "react";

type AddressValue = {
  street1: string;
  street2: string;
  city: string;
  state: string;
  postalCode: string;
  deliveryNotes: string;
};

type CustomerRow = {
  id: string;
  customer: string;
  address: AddressValue;
};

const formatAddress = (value: AddressValue) =>
  [
    [value.street1, value.street2].filter(Boolean).join(", "),
    [value.city, [value.state, value.postalCode].filter(Boolean).join(" ")]
      .filter(Boolean)
      .join(", "),
    value.deliveryNotes ? `Notes: ${value.deliveryNotes}` : undefined,
  ]
    .filter(Boolean)
    .join("\n");

const initialData: TableRow<CustomerRow>[] = [
  {
    id: "1",
    customer: "Bayside Coffee",
    address: {
      street1: "188 Market Street",
      street2: "Suite 400",
      city: "San Francisco",
      state: "CA",
      postalCode: "94105",
      deliveryNotes: "Leave with front desk before 3 PM.",
    },
  },
];

function App() {
  const [data, setData] = useState(initialData);
  const createColumn = createColumnHelper<CustomerRow>();

  const columns = [
    createColumn("customer", {
      headerLabel: "Customer",
      sortable: true,
    }),
    createColumn("address", {
      headerLabel: "Address",
      minWidth: 280,
      sortable: true,
      renderCell: (value) => (
        <span style={{ whiteSpace: "pre-line" }}>{formatAddress(value)}</span>
      ),
      getReadRenderResult: (value) => ({
        content: (
          <span style={{ whiteSpace: "pre-line" }}>{formatAddress(value)}</span>
        ),
        rawString: formatAddress(value),
      }),
      editConfig: {
        mode: "custom",
        surface: {
          title: "Edit address",
          width: 360,
          maxHeight: 480,
          closeButtonLabel: "Save address changes",
        },
        validateDraft: (draftValue) => ({
          formError: draftValue.street1
            ? undefined
            : "Street address is required.",
          fieldErrors: draftValue.street1
            ? undefined
            : { street1: "Enter a street address." },
        }),
        onRequestClose: (request) => {
          if (request.reason !== "escape" && request.validation.formError) {
            return false;
          }
        },
        onChange: (value, rowId) => {
          setData((prev) =>
            prev.map((row) =>
              row.id === rowId ? { ...row, address: value } : row,
            ),
          );
        },
        renderEditor: ({ controller, rowId }) => {
          const changedFieldsLabel = controller.changedFields
            .map(String)
            .join(", ");

          return (
            <form onSubmit={controller.handleSubmit}>
              <Flex direction="column" gap={12}>
                <TextField
                  ref={(element) => {
                    controller.setInitialFocus(
                      element ? () => element.focus() : null,
                    );
                  }}
                  label="Street address"
                  value={controller.draftValue.street1}
                  onChange={(event) =>
                    controller.updateDraftValue("street1", event.target.value)
                  }
                  error={controller.validation.fieldErrors?.street1}
                />
                <TextField
                  label="City"
                  value={controller.draftValue.city}
                  onChange={(event) =>
                    controller.updateDraftValue("city", event.target.value)
                  }
                />
                <Flex gap={12}>
                  <TextField
                    label="State"
                    maxLength={2}
                    value={controller.draftValue.state}
                    onChange={(event) =>
                      controller.updateDraftValue(
                        "state",
                        event.target.value.toUpperCase(),
                      )
                    }
                  />
                  <TextField
                    label="Postal code"
                    inputMode="numeric"
                    value={controller.draftValue.postalCode}
                    onChange={(event) =>
                      controller.updateDraftValue(
                        "postalCode",
                        event.target.value.replace(/[^\d]/g, "").slice(0, 5),
                      )
                    }
                  />
                </Flex>
                <Textarea
                  label="Delivery notes"
                  autoHeight
                  minRows={3}
                  value={controller.draftValue.deliveryNotes}
                  onChange={(event) =>
                    controller.updateDraftValue(
                      "deliveryNotes",
                      event.target.value,
                    )
                  }
                />

                {controller.validation.formError ? (
                  <div>{controller.validation.formError}</div>
                ) : null}

                {controller.isDirty ? (
                  <div>
                    Row {rowId} changed fields: {changedFieldsLabel || "none"}
                  </div>
                ) : null}

                <Flex gap={8}>
                  <Button type="submit">Save</Button>
                  <Button type="button" onClick={controller.discard}>
                    Discard
                  </Button>
                </Flex>
              </Flex>
            </form>
          );
        },
      },
    }),
  ];

  return <DataTable data={data} columns={columns} />;
}
```

For draft callbacks, close interception, and focus details, see [Custom Edit Mode](https://anvil.servicetitan.com/docs/components/data-table/beta-changes/custom-edit-mode.md).

### Empty cell content

By default, cells with empty values (`null`, `undefined`, or empty string) display an em dash (—). Customize this at the table or column level using the `emptyCellContent` prop.

#### Table-wide default

Set `emptyCellContent` on the `DataTable` to change the empty cell display for all columns:

```tsx theme={null}
<DataTable
  data={rowData}
  columns={columns}
  emptyCellContent="N/A"
/>
```

#### Per-column override

Set `emptyCellContent` on individual column definitions to override the table default for specific columns:

```tsx theme={null}
const columns = [
  createColumn("customer_name", {
    header: { label: "Customer" },
  }),
  createColumn("scheduled_date", {
    header: { label: "Scheduled" },
    type: "date",
    emptyCellContent: "No date",
  }),
  createColumn("notes", {
    header: { label: "Notes" },
    emptyCellContent: "No notes",
  }),
];
```

Column-level values take precedence over the table-level default. When neither is set, the default em dash (—) is displayed.

## Working with rows

### Defining row data

To show content within the data table body cells, the `DataTable.data` prop should be passed an array of objects. For type safety, use the `TableRow` type from Anvil2, and pass the same type interface that was used for the `createColumnHelper` function as the generic parameter:

```ts theme={null}
import { type TableRow } from "@servicetitan/anvil2/beta";

const rowData: TableRow<TableColumns>[] = [
  {
    id: "row-1",
    customer_name: "Jose Ramirez";
    address: "2401 Ontario Street, Cleveland, OH";
    status: "active";
    amount_due: 1276.43;
    paid_percent: 0.61;
  }
]
```

```tsx theme={null}
<DataTable data={rowData} columns={columns} />
```

> [!NOTE]
>
> Row data for data tables with grouped columns do not need to include the `group` id in the data structure. The parameters of the objects in the array passed to the `data` prop should map to the lowest-level column `id`, rather than the top-level groups.

### Loading `data` async

The `DataTable.data` prop can also accept a `Promise` that resolves `TableRow<T>[]`. While the `Promise` is pending, a loading spinner will be rendered in the data table.

```tsx theme={null}
async function fetchData() {
  // fetch data from the server
  // this should return TableRow<TableColumns>[]
}
return <DataTable data={fetchData()} columns={columns} />;
```

### Empty states

Use the `emptyState` prop to display a customizable empty state when the table has no data. The prop accepts an object with an optional `svg` illustration component and optional `content` (any ReactNode).

data-table-empty-state

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

```tsx lines expandable theme={null}
import { Text, Flex, Link } from "@servicetitan/anvil2";
import { DataTable, createColumnHelper } from "@servicetitan/anvil2/beta";
import { EmptyStateNoSearchResults as NoSearchResults } from "@servicetitan/anvil2-illustrations";

type OrderData = {
  id: string;
  customer: string;
  amount: number;
  status: string;
};

const createColumn = createColumnHelper<OrderData>();

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
  }),
  createColumn("customer", {
    header: { label: "Customer" },
  }),
  createColumn("amount", {
    header: { label: "Amount" },
  }),
  createColumn("status", {
    header: { label: "Status" },
  }),
];

function App() {
  return (
    <DataTable
      data={[]}
      columns={columns}
      emptyState={{
        svg: NoSearchResults,
        content: (
          <Flex
            alignItems="center"
            direction="column"
            gap={2}
            style={{ maxWidth: "420px" }}
          >
            <Text subdued size="small" style={{ textAlign: "center" }}>
              No results found. Try another search or{" "}
              <Link href="#">create a new report</Link>.
            </Text>
          </Flex>
        ),
      }}
    />
  );
}

export default App;
```

### Indicating errors on rows and cells

DataTable supports displaying error states on individual cells or entire rows. Errors are indicated with a red border, red text, and an error icon. Use the `meta` property on row data to set errors.

> [!NOTE]
>
> When setting errors, prefer using descriptive string messages instead of boolean values. String messages provide accessible context that screen readers announce, helping users understand why a cell or row is in an error state.

#### Cell-level errors

data-table-cell-errors

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

```tsx lines expandable theme={null}
import {
  DataTable,
  createColumnHelper,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type OrderData = {
  id: string;
  customer_name: string;
  amount: number;
  status: string;
  note: string;
};

const createColumn = createColumnHelper<OrderData>();

// Data with cell-level errors
// Use string error messages to provide context about what's wrong
const data: TableRow<OrderData>[] = [
  {
    id: "ORD-001",
    customer_name: "",
    amount: 2450.75,
    status: "completed",
    note: "Regular order",
    // String error message provides accessible context
    meta: { errors: { customer_name: "Customer name is required" } },
  } as TableRow<OrderData>,
  {
    id: "ORD-002",
    customer_name: "TechCorp Solutions",
    amount: 15750.0,
    status: "shipped",
    note: "Priority shipment",
    // No errors on this row
  },
  {
    id: "ORD-003",
    customer_name: "Ahmed Hassan",
    amount: -50.0,
    status: "pending",
    note: "",
    // Multiple cells can have errors
    meta: {
      errors: {
        amount: "Amount must be positive",
        note: "Note is required for pending orders",
      },
    },
  } as TableRow<OrderData>,
  {
    id: "ORD-004",
    customer_name: "Maria Gonzalez",
    amount: 1200.5,
    status: "processing",
    note: "This is an extremely long note that exceeds the maximum allowed character limit for this field",
    meta: { errors: { note: "Note exceeds 100 character limit" } },
  } as TableRow<OrderData>,
];

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    minWidth: 100,
  }),
  createColumn("customer_name", {
    header: { label: "Customer Name" },
    minWidth: 150,
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    minWidth: 100,
  }),
  createColumn("status", {
    header: { label: "Status" },
    minWidth: 100,
  }),
  createColumn("note", {
    header: { label: "Note" },
    minWidth: 200,
  }),
];

function App() {
  return <DataTable data={data} columns={columns} />;
}

export default App;
```

Set cell-level errors using the `meta.errors` property. This is an object keyed by column `id`, where the value is either a string message or `true`:

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

const rowData: TableRow<TableColumns>[] = [
  {
    id: "row-1",
    customer_name: "",
    amount: 1276.43,
    status: "active",
    meta: {
      errors: {
        // Preferred: string message provides accessible context
        customer_name: "Customer name is required",
      },
    },
  },
  {
    id: "row-2",
    customer_name: "Jose Ramirez",
    amount: -50,
    status: "pending",
    meta: {
      errors: {
        // Multiple cells can have errors
        amount: "Amount must be positive",
        status: "Status is invalid for this order type",
      },
    },
  },
];
```

The error icon displays in the cell. When a string message is provided, hovering or focusing the icon shows a tooltip with the error message, and screen readers announce the error.

#### Row-level errors

data-table-row-errors

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

```tsx lines expandable theme={null}
import {
  DataTable,
  createColumnHelper,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type OrderData = {
  id: string;
  customer_name: string;
  amount: number;
  status: string;
};

const createColumn = createColumnHelper<OrderData>();

// Data with row-level errors
// Use string error messages to provide context about what's wrong with the entire row
const data: TableRow<OrderData>[] = [
  {
    id: "ORD-001",
    customer_name: "Sophia Rodriguez",
    amount: 2450.75,
    status: "completed",
    // String error message provides accessible context
    meta: { rowError: "This order has validation issues that need review" },
  } as TableRow<OrderData>,
  {
    id: "ORD-002",
    customer_name: "TechCorp Solutions",
    amount: 15750.0,
    status: "shipped",
    // No error on this row
  },
  {
    id: "ORD-003",
    customer_name: "Ahmed Hassan",
    amount: 89.99,
    status: "pending",
    meta: { rowError: "Missing required shipping information" },
  } as TableRow<OrderData>,
  {
    id: "ORD-004",
    customer_name: "Maria Gonzalez",
    amount: 1200.5,
    status: "processing",
    // No error on this row
  },
  {
    id: "ORD-005",
    customer_name: "Chen Wei",
    amount: 0.0,
    status: "cancelled",
    meta: { rowError: "Order was cancelled due to payment failure" },
  } as TableRow<OrderData>,
];

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    minWidth: 120,
  }),
  createColumn("customer_name", {
    header: { label: "Customer Name" },
    minWidth: 180,
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    minWidth: 100,
  }),
  createColumn("status", {
    header: { label: "Status" },
    minWidth: 100,
  }),
];

function App() {
  return <DataTable data={data} columns={columns} />;
}

export default App;
```

Set row-level errors using the `meta.rowError` property. This displays an error icon in a dedicated column at the start of the row:

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

const rowData: TableRow<TableColumns>[] = [
  {
    id: "row-1",
    customer_name: "Sophia Rodriguez",
    amount: 2450.75,
    status: "completed",
    meta: {
      // Preferred: string message provides accessible context
      rowError: "This order has validation issues that need review",
    },
  },
  {
    id: "row-2",
    customer_name: "Chen Wei",
    amount: 0,
    status: "cancelled",
    meta: {
      rowError: "Missing required shipping information",
    },
  },
];
```

The row error column automatically appears when any row has a `rowError` set, and is pinned to the left side of the table along with other internal columns (selection, expansion).

#### Combining cell and row errors

Cell-level and row-level errors can be used together. Use row-level errors for general validation issues that affect the entire row, and cell-level errors for specific field validation:

```tsx theme={null}
const rowData: TableRow<TableColumns>[] = [
  {
    id: "row-1",
    customer_name: "",
    amount: -50,
    status: "pending",
    meta: {
      // Row-level error for the overall issue
      rowError: "Order cannot be processed due to validation errors",
      // Cell-level errors for specific fields
      errors: {
        customer_name: "Customer name is required",
        amount: "Amount must be positive",
      },
    },
  },
];
```

### Indicating warnings on rows and cells

DataTable supports displaying warning states on individual cells or entire rows. Warnings are indicated with a yellow border and a warning icon, and are used for non-blocking advisory issues. Use the `meta` property on row data to set warnings.

> [!NOTE]
>
> When setting warnings, prefer using descriptive string messages instead of boolean values. String messages provide accessible context that screen readers announce, helping users understand why a cell or row is in a warning state.

#### Cell-level warnings

data-table-cell-warnings

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

```tsx lines expandable theme={null}
import {
  DataTable,
  createColumnHelper,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type OrderData = {
  id: string;
  customer_name: string;
  amount: number;
  status: string;
  note: string;
};

const createColumn = createColumnHelper<OrderData>();

// Data with cell-level warnings
// Use string warning messages to provide context about advisory issues
const data: TableRow<OrderData>[] = [
  {
    id: "ORD-001",
    customer_name: "Sophia Rodriguez",
    amount: 2450.75,
    status: "completed",
    note: "Regular order",
    // String warning message provides accessible context
    meta: { warnings: { customer_name: "Customer name may be incomplete" } },
  } as TableRow<OrderData>,
  {
    id: "ORD-002",
    customer_name: "TechCorp Solutions",
    amount: 15750.0,
    status: "shipped",
    note: "Priority shipment",
    // No warnings on this row
  },
  {
    id: "ORD-003",
    customer_name: "Ahmed Hassan",
    amount: 89.99,
    status: "pending",
    note: "",
    // Multiple cells can have warnings
    meta: {
      warnings: {
        amount: "Amount is below the typical order minimum",
        note: "Consider adding a note for pending orders",
      },
    },
  } as TableRow<OrderData>,
  {
    id: "ORD-004",
    customer_name: "Maria Gonzalez",
    amount: 1200.5,
    status: "processing",
    note: "This is an extremely long note that may exceed the recommended character limit for this field",
    meta: { warnings: { note: "Note is approaching the 100 character limit" } },
  } as TableRow<OrderData>,
];

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    minWidth: 100,
  }),
  createColumn("customer_name", {
    header: { label: "Customer Name" },
    minWidth: 150,
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    minWidth: 100,
  }),
  createColumn("status", {
    header: { label: "Status" },
    minWidth: 100,
  }),
  createColumn("note", {
    header: { label: "Note" },
    minWidth: 200,
  }),
];

function App() {
  return <DataTable data={data} columns={columns} />;
}

export default App;
```

Set cell-level warnings using the `meta.warnings` property. This is an object keyed by column `id`, where the value is either a string message or `true`:

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

const rowData: TableRow<TableColumns>[] = [
  {
    id: "row-1",
    customer_name: "Sophia Rodriguez",
    amount: 89.99,
    status: "pending",
    meta: {
      warnings: {
        // Preferred: string message provides accessible context
        amount: "Amount is below the typical order minimum",
      },
    },
  },
  {
    id: "row-2",
    customer_name: "Jose Ramirez",
    amount: 1200.5,
    status: "processing",
    meta: {
      warnings: {
        // Multiple cells can have warnings
        amount: "Amount exceeds the standard discount threshold",
        status: "Processing orders may experience delays",
      },
    },
  },
];
```

The warning icon displays in the cell. When a string message is provided, hovering or focusing the icon shows a tooltip with the warning message, and screen readers announce the warning.

#### Row-level warnings

data-table-row-warnings

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

```tsx lines expandable theme={null}
import {
  DataTable,
  createColumnHelper,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type OrderData = {
  id: string;
  customer_name: string;
  amount: number;
  status: string;
};

const createColumn = createColumnHelper<OrderData>();

// Data with row-level warnings
// Use string warning messages to provide context about advisory issues for the entire row
const data: TableRow<OrderData>[] = [
  {
    id: "ORD-001",
    customer_name: "Sophia Rodriguez",
    amount: 2450.75,
    status: "completed",
    // String warning message provides accessible context
    meta: { rowWarning: "This order has unusual pricing that may need review" },
  } as TableRow<OrderData>,
  {
    id: "ORD-002",
    customer_name: "TechCorp Solutions",
    amount: 15750.0,
    status: "shipped",
    // No warning on this row
  },
  {
    id: "ORD-003",
    customer_name: "Ahmed Hassan",
    amount: 89.99,
    status: "pending",
    meta: { rowWarning: "Shipping address has not been verified" },
  } as TableRow<OrderData>,
  {
    id: "ORD-004",
    customer_name: "Maria Gonzalez",
    amount: 1200.5,
    status: "processing",
    // No warning on this row
  },
  {
    id: "ORD-005",
    customer_name: "Chen Wei",
    amount: 0.0,
    status: "cancelled",
    meta: {
      rowWarning: "Order was cancelled but payment refund is still pending",
    },
  } as TableRow<OrderData>,
];

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    minWidth: 120,
  }),
  createColumn("customer_name", {
    header: { label: "Customer Name" },
    minWidth: 180,
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    minWidth: 100,
  }),
  createColumn("status", {
    header: { label: "Status" },
    minWidth: 100,
  }),
];

function App() {
  return <DataTable data={data} columns={columns} />;
}

export default App;
```

Set row-level warnings using the `meta.rowWarning` property. This displays a warning icon in a dedicated column at the start of the row:

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

const rowData: TableRow<TableColumns>[] = [
  {
    id: "row-1",
    customer_name: "Sophia Rodriguez",
    amount: 2450.75,
    status: "completed",
    meta: {
      // Preferred: string message provides accessible context
      rowWarning: "This order has unusual pricing that may need review",
    },
  },
  {
    id: "row-2",
    customer_name: "Chen Wei",
    amount: 0,
    status: "cancelled",
    meta: {
      rowWarning: "Shipping address has not been verified",
    },
  },
];
```

The row warning column automatically appears when any row has a `rowWarning` set, and is pinned to the left side of the table along with other internal columns (selection, expansion).

#### Combining cell and row warnings

Cell-level and row-level warnings can be used together. Use row-level warnings for general advisory issues that affect the entire row, and cell-level warnings for specific field-level advisories:

```tsx theme={null}
const rowData: TableRow<TableColumns>[] = [
  {
    id: "row-1",
    customer_name: "Sophia Rodriguez",
    amount: 89.99,
    status: "pending",
    meta: {
      // Row-level warning for the overall advisory
      rowWarning: "This order may require additional review",
      // Cell-level warnings for specific fields
      warnings: {
        amount: "Amount is below the typical order minimum",
        status: "Pending orders older than 7 days are auto-cancelled",
      },
    },
  },
];
```

#### Error and warning priority

When both errors and warnings exist on the same cell or row, errors take priority. The error styling and icon are displayed, and the warning is hidden. This ensures that blocking validation issues are always surfaced over advisory messages:

```tsx theme={null}
const rowData: TableRow<TableColumns>[] = [
  {
    id: "row-1",
    customer_name: "",
    amount: -50,
    status: "pending",
    meta: {
      // Error takes priority over warning for the row indicator
      rowError: "Order cannot be processed due to validation errors",
      rowWarning: "This order may require additional review",
      // Error takes priority over warning on the customer_name cell
      errors: { customer_name: "Customer name is required" },
      // Warning on customer_name is hidden because the error takes priority
      // Warning on amount displays since there is no error on that cell
      warnings: {
        customer_name: "Customer name format is unusual",
        amount: "Amount is below the typical order minimum",
      },
    },
  },
];
```

### Expanding rows

There are two methods for expanding rows to show additional content:

1. **Sub-rows**: the expanded content includes additional rows of data the have the same columns of the parent row.
2. **Sub-components**: the expanded content includes arbitrary content that is considered "off-grid", or not part of the table data itself.

Both of these are defined in the `DataTable.data` array, using the `subRows` or `subComponent` properties.

#### Sub-rows

The `subRows` property will have the same type requirements as the top-level rows to ensure the data maps to the same column structure.

data-table-expanded-rows

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

```tsx lines expandable theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

import { useState } from "react";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  customer_name: string;
  items: number;
  payment_type: PaymentType;
  note?: string;
};

const data: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    payment_type: "credit_card",
    items: 12,
    customer_name: "Sophia Rodriguez",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    payment_type: "bank_transfer",
    items: 45,
    customer_name: "TechCorp Solutions",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    items: 2,
    customer_name: "Ahmed Hassan",
    order_date: "09/20/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    payment_type: "credit_card",
    customer_name: "Maria Gonzalez",
    items: 8,
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    payment_type: "credit_card",
    customer_name: "Chen Wei",
    items: 1,
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    payment_type: "check",
    customer_name: "Green Valley Farms",
    items: 156,
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    customer_name: "Jennifer Kim",
    items: 1,
    status: ["shipped"],
    order_date: "09/21/2024",
    payment_type: "cash",
  },
];

const createColumn = createColumnHelper<OrderData>();

const statusOrder: Record<Status, number> = {
  pending: 1,
  processing: 2,
  shipped: 3,
  completed: 4,
  cancelled: 5,
};

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
  const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
  const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

  return statusA - statusB;
};

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    sortable: true,
    resizable: true,
    minWidth: 130,
    footerContent: (
      <span>
        <b>Total:</b> {data.length}
      </span>
    ),
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
    footerContent: [
      <span key="total">
        {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
      </span>,
      <span key="average">
        {currencyFormatter(
          data.reduce((acc, row) => acc + row.amount, 0) / data.length,
        )}{" "}
        (avg)
      </span>,
    ],
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
    sortable: sortStatus,
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

const expandedRowData: TableRow<OrderData>[] = data.map((row) => ({
  ...row,
  items: row.items * 2,
  subRows: [
    {
      ...row,
      id: `${row.id}-0`,
      items: row.items + 1,
      amount: row.amount / 2,
      status: undefined,
      note: undefined,
    },
    {
      ...row,
      id: `${row.id}-1`,
      items: row.items - 1,
      amount: row.amount / 2,
      status: undefined,
      note: undefined,
    },
  ],
}));

function App() {
  const [selectedRowIds, setSelectedRowIds] = useState<
    TableRow<typeof data>[0]["id"][]
  >(["ORD-2024-002", "ORD-2024-002-0"]);

  const handleSelection = (
    newSelectedRowIds: TableRow<typeof data>[0]["id"][],
  ) => {
    setSelectedRowIds(newSelectedRowIds);
  };

  return (
    <DataTable
      data={expandedRowData}
      columns={columns}
      isSelectable
      onSelectRow={handleSelection}
      selectedRowIds={selectedRowIds}
      defaultExpandedRowIds={["ORD-2024-002"]}
    />
  );
}

export default App;
```

#### Sub-components

The `subComponent` property accepts a `ReactNode`, which is rendered in the expanded area. Some padding is added around the component, and the direct parent is a flex container.

data-table-sub-component

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

```tsx lines expandable theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";
import { Card, Text, Button } from "@servicetitan/anvil2";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  customer_name: string;
  items: number;
  payment_type: PaymentType;
  note?: string;
};

const data: OrderData[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    payment_type: "credit_card",
    items: 12,
    customer_name: "Sophia Rodriguez",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    payment_type: "bank_transfer",
    items: 45,
    customer_name: "TechCorp Solutions",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    items: 2,
    customer_name: "Ahmed Hassan",
    order_date: "09/20/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    payment_type: "credit_card",
    customer_name: "Maria Gonzalez",
    items: 8,
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    payment_type: "credit_card",
    customer_name: "Chen Wei",
    items: 1,
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    payment_type: "check",
    customer_name: "Green Valley Farms",
    items: 156,
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    customer_name: "Jennifer Kim",
    items: 1,
    status: ["shipped"],
    order_date: "09/21/2024",
    payment_type: "cash",
  },
  {
    id: "ORD-2024-008",
    amount: 3780.25,
    customer_name: "Roberto Silva",
    items: 23,
    status: ["processing"],
    order_date: "09/19/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-009",
    amount: 156.78,
    customer_name: "Emily Watson",
    items: 4,
    status: ["completed"],
    order_date: "09/14/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-010",
    amount: 2100.0,
    customer_name: "Moonlight Café",
    items: 35,
    status: ["shipped", "cancelled"],
    order_date: "09/22/2024",
    payment_type: "credit_card",
  },
];

const subComponentsData: TableRow<OrderData>[] = data.map((row) => ({
  ...row,
  subComponent: (
    <Card flexDirection="column" gap="2" style={{ width: "500px" }}>
      <Text variant="headline" size="small" el="h2">
        Sub Component
      </Text>
      <Text variant="body">
        Tab press should focus the button. Second Tab press should focus the
        next cell in the table. Shift+Tab press should focus the previous cell
        in the table.
      </Text>
      <Button size="small" appearance="primary">
        Focus Test
      </Button>
    </Card>
  ),
}));

const createColumn = createColumnHelper<OrderData>();

const statusOrder: Record<Status, number> = {
  pending: 1,
  processing: 2,
  shipped: 3,
  completed: 4,
  cancelled: 5,
};

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
  const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
  const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

  return statusA - statusB;
};

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    sortable: true,
    resizable: true,
    minWidth: 130,
    footerContent: (
      <span>
        <b>Total:</b> {data.length}
      </span>
    ),
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
    footerContent: [
      <span key="total">
        {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
      </span>,
      <span key="average">
        {currencyFormatter(
          data.reduce((acc, row) => acc + row.amount, 0) / data.length,
        )}{" "}
        (avg)
      </span>,
    ],
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
    sortable: sortStatus,
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

function App() {
  return (
    <DataTable
      data={subComponentsData}
      columns={columns}
      defaultExpandedRowIds={["ORD-2024-001"]}
    />
  );
}

export default App;
```

> [!NOTE]
>
> When a user tabs to a sub-component, they exit the keyboard navigation of the data table, and can interact with the elements of the sub-component until they tab back to the data table cell.

#### Controlling expanded state

By default, the expanded state of rows is uncontrolled. The `DataGrid.defaultExpandedRowIds` prop can be used to expand particular rows by default.

To control the expanded state of rows, use `expandedRowIds` with `onExpandRow`:

```tsx theme={null}
const [expandedRowIds, setExpandedRowIds] = useState<string[]>(["row-1"]);
return (
  <DataTable
    data={rowData}
    columns={columns}
    expandedRowIds={expandedRowIds}
    onExpandRow={setExpandedRowIds}
  />
);
```

> [!NOTE]
>
> The `expandedRowIds` should correspond to the parent row that expands, not the sub-rows.

### Selecting rows

Row selection can be enabled using the `DataTable.isSelectable` prop. When `true`, a new column is added as the first column with a checkbox to select the row.

#### Uncontrolled selection

The `defaultSelectedRowIds` can be used to select specific rows on load, but keep the selection state uncontrolled.

```tsx theme={null}
<DataTable
  data={rowData}
  columns={columns}
  isSelectable
  defaultSelectedRowIds={["row-1"]}
  onSelectRow={(selectedIds: string[]) => {
    // do something with the selected rows
  }}
/>
```

#### Controlled selection

The `selectedRowIds` and `onSelectRow` can be used to control selection state.

data-table-controlled-selection

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

```tsx lines expandable theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

import { useState } from "react";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  payment_type: PaymentType;
};

const data: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    order_date: "09/20/2024",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    payment_type: "check",
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    status: ["shipped"],
    order_date: "09/21/2024",
    payment_type: "cash",
  },
];

const createColumn = createColumnHelper<OrderData>();

const statusOrder: Record<Status, number> = {
  pending: 1,
  processing: 2,
  shipped: 3,
  completed: 4,
  cancelled: 5,
};

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
  const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
  const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

  return statusA - statusB;
};

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    sortable: true,
    resizable: true,
    minWidth: 130,
    footerContent: (
      <span>
        <b>Total:</b> {data.length}
      </span>
    ),
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
    footerContent: [
      <span key="total">
        {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
      </span>,
      <span key="average">
        {currencyFormatter(
          data.reduce((acc, row) => acc + row.amount, 0) / data.length,
        )}{" "}
        (avg)
      </span>,
    ],
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
    sortable: sortStatus,
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

function App() {
  const [selectedRowIds, setSelectedRowIds] = useState<
    TableRow<typeof data>[0]["id"][]
  >(["ORD-2024-002", "ORD-2024-002-0"]);

  const handleSelection = (
    newSelectedRowIds: TableRow<typeof data>[0]["id"][],
  ) => {
    setSelectedRowIds(newSelectedRowIds);
  };

  return (
    <DataTable
      data={data}
      columns={columns}
      isSelectable
      onSelectRow={handleSelection}
      selectedRowIds={selectedRowIds}
      defaultExpandedRowIds={["ORD-2024-002"]}
    />
  );
}

export default App;
```

### Read-only rows

Pass row IDs to `readOnlyRowIds` to render those rows as read-only. Read-only rows gray their plain-text cell content, show a not-allowed cursor on the row surface, and render the selection checkbox as disabled and read-only. Links, chips, and custom cell content keep their own color.

Pre-seeded `selectedRowIds` is still honored — `readOnlyRowIds` only gates user interaction (checkbox toggling), not the visual selection state.

```tsx theme={null}
<DataTable
  data={rowData}
  columns={columns}
  isSelectable
  readOnlyRowIds={["row-3", "row-4"]}
/>
```

### Clicking rows

Pass `onClickRow` to be notified when a user clicks a row or activates it from the keyboard. The callback receives the clicked row's `id`, in the same form as `onSelectRow`. Clickable rows show a pointer cursor.

Row click stays independent of selection: clicking the selection checkbox toggles selection without firing `onClickRow`, and clicking elsewhere on the row fires `onClickRow` without toggling selection. Clicks on interactive cell content (links, buttons, menus) and on editable cells do not fire `onClickRow`, and read-only rows (`readOnlyRowIds`) never fire it.

Keyboard users activate a row by focusing a non-interactive cell and pressing "Enter".

```tsx theme={null}
<DataTable
  data={rowData}
  columns={columns}
  onClickRow={(rowId) => navigate(`/orders/${rowId}`)}
/>
```

Custom cell content with its own click behavior must use a real interactive element (`button`, `a`) or carry an interactive role such as `role="button"`. The `DataTable` recognizes those and excludes them from row click, and they are also required for keyboard and screen-reader accessibility. A click handler on a bare `div` or `span` with no role still bubbles to the row and fires `onClickRow` — call `event.stopPropagation()` in that handler to opt the element out.

### Bulk actions

Pass `bulkActions` to a selectable table to show a bar above the table while rows are selected. The bar shows the selection count, a "Cancel" button that clears the selection, and up to two actions: an optional secondary action and a primary action. Use them for the actions users take most often.

```tsx theme={null}
<DataTable
  data={rowData}
  columns={columns}
  isSelectable
  bulkActions={{
    secondary: {
      label: "Hide Automations",
      icon: VisibilityOff,
      onClick: (selection) => hideAutomations(selection),
    },
    primary: {
      label: "Show Automations",
      icon: Visibility,
      onClick: (selection) => showAutomations(selection),
    },
  }}
/>
```

Each action receives the current selection:

* `{ mode: "rows", rowIds }` when users checked rows. Like `onSelectRow`, the ids are strings, so convert numeric ids before sending them to your backend.
* `{ mode: "all", selectedRowCount }` when users chose "Select all N items". `selectedRowCount` is the count shown in the bar. Rows that haven't loaded are included, so apply the change on the server. Rows that `isSelectable` rejects and rows in `readOnlyRowIds` are excluded, and the server must skip them too.

The selection clears after an action returns, or after its promise resolves. While a promise is pending, the button shows a spinner and the other actions are disabled. If the promise rejects, the rows stay selected, so you can show the error in a toast and users can try again.

#### Toggle actions

When an action is a toggle, such as hiding and showing an item, provide both states as a secondary and a primary action. The selection can mix both states; the action users choose overrides every selected row.

#### Singular and plural labels

Pass a function as `label` to change the wording with the selection. It receives the selected count, which is the "Select all" count after users choose "Select all N items". This works on every action, including the menu button and its items.

```tsx theme={null}
secondary: {
  label: (count) => (count === 1 ? "Hide Automation" : "Hide Automations"),
  onClick: hideAutomations,
},
```

Translated apps can call their own plural helper inside the function.

#### Many actions

When a table needs more than two bulk actions, pass `items` instead of `onClick` to the primary action to open a menu of actions. Each item takes `label`, `onClick`, and optionally `icon`, `disabled`, and `data-*` attributes.

```tsx theme={null}
bulkActions={{
  primary: {
    label: "Actions",
    items: [
      { label: "Change Prices", onClick: changePrices },
      { label: "Renew Agreements", onClick: renewAgreements },
    ],
  },
}}
```

#### Destructive actions

Use `appearance: "danger"` on the primary action, or `"danger-secondary"` on a secondary action. Return `false` to keep the rows selected while a confirmation dialog is open, then call `clearSelection()` on the ref once users confirm. If users cancel the dialog, the rows stay selected.

```tsx theme={null}
const tableRef = useRef<DataTableRef>(null);

<DataTable
  ref={tableRef}
  data={rowData}
  columns={columns}
  isSelectable
  bulkActions={{
    primary: {
      label: "Delete Rows",
      icon: Delete,
      appearance: "danger",
      onClick: (selection) => {
        openConfirmDialog(selection);
        return false;
      },
    },
  }}
/>;

// After users confirm
tableRef.current?.clearSelection();
```

#### Selecting all rows across pages

Set `selectAll: true` to show a "Select all N items" button in the bar. It needs offset pagination with a known total (`totalRowCount`, or the length of `data`), and it's hidden in cursor mode or when every row fits on one page. The header checkbox still selects only the current page.

The count includes sub-rows and leaves out rows users can't select. When some rows can't be selected, the button reads "Select all N items of N". With `data`, the table counts the rows itself. With `loadPageData`, it can't see rows that haven't loaded, so it uses `totalRowCount` unless you pass `selectableRowCount`:

```tsx theme={null}
<DataTable
  isSelectable
  pagination={{ rowsPerPage: 25, totalRowCount, loadPageData }}
  bulkActions={{
    selectAll: true,
    // From your API: selectable rows, including sub-rows
    selectableRowCount,
    primary: { label: "Archive", onClick: archive },
  }}
/>
```

#### With filters and toolbar actions

While rows are selected, the bulk action bar replaces the [filter bar and toolbar](#filters-and-toolbar-actions). They stay mounted, so in-progress filter input is kept.

`className`, `style`, layout props, and `data-*` attributes always apply to the table, not to the area above it. A height set on the table doesn't include the filter bar or bulk action bar.

#### Accessibility

* The selection count is announced through a polite live region, for example "4 items selected".
* Selecting a row doesn't move focus to the bar.
* "Cancel" moves focus to the select-all checkbox in the table header. So does a completed action, when the bar closing would otherwise drop focus.

## Filters and toolbar actions

Use the `filters` prop to render a [Filter Bar](https://anvil.servicetitan.com/docs/components/filter-bar/web) above the table. Pass the same filter definitions you would pass to `FilterBar` as `filters.items`. Use the `tools` prop to add buttons, such as Export, to the right of the filter bar.

`DataTable` does not filter rows itself. It reports changes through `filters.onChange`, and your code applies the filters to `data` or to your `loadPageData` request.

### Client-side filtering

Apply the filters from `filters.onChange` to your data and pass the result back as `data`. Memoize the `filters.items` array. A new array identity re-syncs the filter bar and discards in-progress input.

```tsx theme={null}
import { useCallback, useMemo, useState } from "react";
import { DataTable, type Filter } from "@servicetitan/anvil2/beta";

function OrdersTable() {
  const [rows, setRows] = useState(orders);

  const filters = useMemo<Filter[]>(
    () => [
      { id: "search", type: "search", label: "Search", placeholder: "Search customers" },
      {
        id: "payment_type",
        type: "multiSelect",
        label: "Payment type",
        options: [
          { id: "credit_card", label: "Credit card" },
          { id: "cash", label: "Cash" },
        ],
        selectedOptions: [],
      },
      { id: "large", type: "boolean", label: "Over $5,000", checked: false },
    ],
    [],
  );

  const handleFilterChange = useCallback((next: Filter[]) => {
    setRows(applyFilters(orders, next));
  }, []);

  return (
    <DataTable
      data={rows}
      columns={columns}
      filters={{ items: filters, onChange: handleFilterChange }}
      emptyState={{ content: "No orders match these filters." }}
    />
  );
}
```

### Server-side filtering

With `pagination.loadPageData`, store the filters in state and read them in the loader. When a filter changes, `DataTable` returns to the first page, clears the page cache, and calls `loadPageData` again, so the loader always sees the new filters. Update `totalRowCount` to match the filtered result.

```tsx theme={null}
function OrdersTable() {
  const [filters, setFilters] = useState<Filter[]>(initialFilters);

  const loadPageData = useCallback(
    ({ pageIndex, pageSize }) => fetchOrders({ filters, pageIndex, pageSize }),
    [filters],
  );

  return (
    <DataTable
      columns={columns}
      filters={{ items: initialFilters, onChange: setFilters }}
      pagination={{ rowsPerPage: 25, loadPageData, totalRowCount }}
    />
  );
}
```

### Controlled filtering

By default, inline filters commit each change immediately. Set `filters.controlled` to hold inline commits for filters that have an Apply step. Filters without an Apply step still commit immediately, and the filter drawer always batches its changes until the user applies them.

```tsx theme={null}
<DataTable
  data={rows}
  columns={columns}
  filters={{
    items: filters,
    onChange: handleFilterChange,
    controlled: true,
  }}
/>
```

### Drawer-only filters

Set `drawerOnly: true` on a filter to keep it out of the toolbar and show it only in the filter drawer.

```tsx theme={null}
const filters: Filter[] = [
  { id: "search", type: "search", label: "Search" },
  { id: "large", type: "boolean", label: "Over $5,000", checked: false, drawerOnly: true },
];
```

### Toolbar actions

Pass `tools.actions` to render buttons to the right of the filter bar. Each action takes the props of a toolbar button plus a stable `id`. Use `children` for a text label, or `icon` with an `aria-label` for an icon-only button. Only Anvil2 toolbar buttons are accepted; arbitrary nodes are not.

`tools` works without `filters`. Use `tools.size` and `filters.size` to change the size of the toolbar controls and the filter bar. Both default to `"medium"`.

```tsx theme={null}
<DataTable
  data={data}
  columns={columns}
  tools={{
    actions: [{ id: "export", children: "Export", onClick: handleExport }],
    size: "small",
  }}
/>
```

## Edit columns

Use the `columnEditing` prop to add an Edit Columns button at the end of the toolbar. The button opens a drawer where users can drag columns to reorder them, hide or show them with the eye icon, and pin them to the left of the table with the lock icon. The toolbar appears even when the table has no `filters` or `tools`.

You own the layout. `DataTable` applies `columnEditing.layout` to `columns`, and reports changes through `columnEditing.onLayoutChange`. It doesn't store the layout, so keep it in state, or load and save it wherever your product persists user preferences. An empty layout shows every column in the order of `columns`.

```tsx theme={null}
import { useState } from "react";
import { DataTable, type DataTableColumnPref } from "@servicetitan/anvil2/beta";

function OrdersTable() {
  const [layout, setLayout] = useState<DataTableColumnPref[]>([]);

  return (
    <DataTable
      data={orders}
      columns={columns}
      columnEditing={{ layout, onLayoutChange: setLayout }}
    />
  );
}
```

Each `DataTableColumnPref` has the column `id`, whether it's `visible`, and whether it's `pinned`. Ids that match no column are ignored, and columns missing from the layout are added as visible, so a saved layout keeps working when columns change.

### Applying and resetting

Edits stay in the drawer until the user selects **Apply**. Only then does `onLayoutChange` fire with the full layout. If the user closes the drawer with unapplied edits, the drawer asks them to confirm that they want to discard the changes.

**Reset Defaults** asks for confirmation, then calls `onLayoutChange` with the layout implied by `columns`: the same order, every column visible, and left-pinned columns pinned as declared.

The drawer doesn't let the user hide the last column whose removal would leave the table empty. When an `alwaysVisible` or right-pinned column remains, the user can hide every column in the drawer.

### Always-visible columns

Set `alwaysVisible: true` on a column to keep it out of the drawer. Users can't hide, reorder, or pin it, and it keeps its position in the `columns` array. Columns with `pinned: "right"` are left out of the drawer in the same way. Use this for identifying columns or a row actions column.

`alwaysVisible` doesn't pin a column. A column the user pins still renders ahead of an unpinned `alwaysVisible` column. To keep a column first, also set `pinned: "left"`.

```tsx theme={null}
const columns = [
  columnHelper("name", {
    header: { label: "Name" },
    alwaysVisible: true,
    pinned: "left",
  }),
  columnHelper("status", { header: { label: "Status" } }),
];
```

### Sorting and hidden columns

When the user hides the column that the table is sorted by, `DataTable` clears the sort and calls `onSort` with `undefined`. With `pagination.loadPageData`, the loader stops receiving that column in `sorting`.

### One drawer for several tables

When a page has several tables, a single drawer can edit all of them. Set `columnEditing.hideButton` on each table so it keeps applying its layout without showing its own button. Then render a `DataTableEditColumnsDrawer` with one section for each table, and open it from your own button. Each section takes the same `columns` as its table.

Apply reports only the sections the user changed. Reset Defaults resets every section.

```tsx theme={null}
import { useMemo, useState } from "react";
import {
  DataTable,
  DataTableEditColumnsDrawer,
  type DataTableColumnPref,
} from "@servicetitan/anvil2/beta";

function Dashboard() {
  const [open, setOpen] = useState(false);
  const [ordersLayout, setOrdersLayout] = useState<DataTableColumnPref[]>([]);
  const [customersLayout, setCustomersLayout] = useState<DataTableColumnPref[]>([]);

  const sections = useMemo(
    () => [
      { title: "Orders", columns: orderColumns, layout: ordersLayout, onLayoutChange: setOrdersLayout },
      { title: "Customers", columns: customerColumns, layout: customersLayout, onLayoutChange: setCustomersLayout },
    ],
    [ordersLayout, customersLayout],
  );

  return (
    <>
      <Button onClick={() => setOpen(true)}>Edit Columns</Button>
      <DataTable
        data={orders}
        columns={orderColumns}
        columnEditing={{ layout: ordersLayout, onLayoutChange: setOrdersLayout, hideButton: true }}
      />
      <DataTable
        data={customers}
        columns={customerColumns}
        columnEditing={{ layout: customersLayout, onLayoutChange: setCustomersLayout, hideButton: true }}
      />
      <DataTableEditColumnsDrawer
        open={open}
        onClose={() => setOpen(false)}
        sections={sections}
      />
    </>
  );
}
```

## Adding rows

Use the `rowAdd` prop to render an Add row button in a toolbar below the table. The button only calls `onAddRow`, so the page owns the flow, such as a dialog or a server-side check. `DataTable` does not know when your flow finishes, so update `data` when it does. If the table loads rows with `pagination.loadPageData`, call `refresh()` on the table's ref instead. Set `loading` to show the button as busy while an external check runs. See [`DataTableRowAdd`](#row-add-datatablerowadd) for every property.

```tsx theme={null}
<DataTable
  data={data}
  columns={columns}
  rowAdd={{ onAddRow: () => setDialogOpen(true) }}
/>
```

### Suggested pattern: a dialog with a form

We suggest opening a dialog that holds a form for the new row. The dialog gives the user room for fields that don't fit in a table cell, and it is the place to validate and to call your API before the row appears.

For the form fields, use the `Form.*` components from [`@servicetitan/anvil2-ext-common`](https://anvil.servicetitan.com/docs/kits/common/form-fields). They bind to a field state, so validation and values live in a store rather than in the dialog. Validate on save, add the saved row to your `data`, and close the dialog when the save succeeds.

```tsx theme={null}
import { useState } from "react";
import { observer } from "mobx-react";
import { FormState } from "formstate";
import { Form, A2InputFieldState } from "@servicetitan/anvil2-ext-common";
import { Button, Dialog } from "@servicetitan/anvil2";
import { DataTable, type TableRow } from "@servicetitan/anvil2/beta";

class NewRequestStore {
  customer = new A2InputFieldState("").validators(
    (value) => !value && "Customer is required",
  );
  form = new FormState({ customer: this.customer });

  // A class field so `this` survives being passed to a prop. Resolves to
  // the saved request, or `undefined` when validation fails.
  save = async (): Promise<TableRow<Request> | undefined> => {
    const result = await this.form.validate();
    if (result.hasError) return undefined;
    return api.createRequest({ customer: this.customer.value });
  };
}

const RequestsTable = observer(function RequestsTable() {
  const [requests, setRequests] = useState<TableRow<Request>[]>([]);
  const [open, setOpen] = useState(false);
  const [saving, setSaving] = useState(false);
  const [store, setStore] = useState(() => new NewRequestStore());

  const close = () => {
    setOpen(false);
    // A fresh store clears the values and errors for the next row.
    setStore(new NewRequestStore());
  };

  const handleSave = async () => {
    setSaving(true);
    try {
      const created = await store.save();
      if (created) {
        setRequests((prev) => [...prev, created]);
        close();
      }
    } finally {
      setSaving(false);
    }
  };

  return (
    <>
      <DataTable
        data={requests}
        columns={columns}
        emptyState={{ content: "No requests yet.", includeAddRow: true }}
        rowAdd={{ onAddRow: () => setOpen(true) }}
      />
      <Dialog open={open} onClose={close}>
        <Dialog.Header>New request</Dialog.Header>
        <Dialog.Content>
          <Form.TextField label="Customer" required fieldState={store.customer} />
        </Dialog.Content>
        <Dialog.Footer>
          <Dialog.CancelButton>Cancel</Dialog.CancelButton>
          <Button appearance="primary" loading={saving} onClick={() => void handleSave()}>
            Save
          </Button>
        </Dialog.Footer>
      </Dialog>
    </>
  );
});
```

### Empty state

A table that starts empty can repeat the Add button in its empty state. Set `emptyState.includeAddRow` and the button appears under the empty state content, with the same label and handler as the toolbar button. It has no effect without `rowAdd`.

```tsx theme={null}
<DataTable
  data={[]}
  columns={columns}
  emptyState={{
    content: "No requests yet. Add one to get started.",
    includeAddRow: true,
  }}
  rowAdd={{ onAddRow: () => setDialogOpen(true) }}
/>
```

## Pagination

For larger data sets, pagination is the preferred method for only rendering a maximum number of rows at a time. This is done with the `DataTable.pagination` prop, which accepts either a `boolean`, or a config object with the following shape:

```ts theme={null}
type DataTablePaginationBaseConfig<T> = {
  /**
   * Number of rows displayed per page
   * @default 25
   */
  rowsPerPage?: number;
  /**
   * Available options for rows per page selection
   */
  rowsPerPageOptions?: number[];
  /**
   * Callback when rows per page changes
   */
  onRowsPerPageChange?: (rowsPerPage: number) => void;
  /**
   * Current page index (0-based)
   */
  currentPageIndex?: number;
  /**
   * Function to get the data for the current page. When used, the data displayed will be
   * fully controlled by the implementor. This is useful for server-side pagination scenarios where
   * the total number of rows may differ from the data array length.
   */
  loadPageData?: ({
    pageIndex,
    pageSize,
    sorting,
  }: {
    pageIndex: number;
    pageSize: number;
    sorting?: SortedColumn;
  }) => TableRow<T>[] | Promise<TableRow<T>[]>;
  /**
   * Default page index for uncontrolled mode (0-based)
   * @default 0
   */
  defaultPageIndex?: number;
  /**
   * Callback when page changes. Receives the new page index (0-based)
   */
  onPageChange?: (pageIndex: number) => void;
  /**
   * Key that triggers data refetch when changed.
   * Useful for forcing refetch when external filters change.
   * When changed, the page index automatically resets to 0.
   */
  refreshKey?: string | number;
  /**
   * Configuration for caching loadPageData results.
   * Caching is enabled by default. Set `{ enabled: false }` to disable.
   */
  cache?: DataTableCacheOptions;
};

export type DataTableOffsetPaginationConfig<T> =
  DataTablePaginationBaseConfig<T> & {
    /**
     * Offset pagination mode.
     * @default "offset"
     */
    mode?: "offset";
    /**
     * Whether to display the item count information in pagination
     * @default true
     */
    showCount?: boolean;
    /**
     * Total number of rows across all pages in offset mode. Falls back to data.length if not provided.
     * Useful for server-side pagination scenarios where totalRowCount may differ from data array length.
     */
    totalRowCount?: number;
    hasNextPage?: never;
    hasPrevPage?: never;
  };

export type DataTableCursorPaginationConfig<T> =
  DataTablePaginationBaseConfig<T> & {
    /**
     * Cursor pagination mode for datasets where the total row count is unknown.
     */
    mode: "cursor";
    /**
     * Whether another page is available.
     */
    hasNextPage: boolean;
    /**
     * Whether a previous page is available.
     */
    hasPrevPage: boolean;
    showCount?: never;
    totalRowCount?: never;
  };

export type DataTablePaginationConfig<T> =
  | DataTableOffsetPaginationConfig<T>
  | DataTableCursorPaginationConfig<T>;
```

### Uncontrolled pagination

data-table-pagination

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

```tsx lines expandable theme={null}
import {
  DataTable,
  createColumnHelper,
  chipsFormatter,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
type PaymentType =
  | "credit_card"
  | "cash"
  | "bank_transfer"
  | "check"
  | "paypal";

type StatusValue = OrderData["status"];

type OrderData = {
  id: string;
  amount: number;
  status?: Status[];
  order_date: string;
  customer_name: string;
  payment_type: PaymentType;
};

const data: TableRow<OrderData>[] = [
  {
    id: "ORD-2024-001",
    amount: 2450.75,
    status: ["completed"],
    order_date: "09/15/2024",
    customer_name: "Sophia Rodriguez",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-002",
    amount: 15750.0,
    status: ["shipped"],
    order_date: "09/18/2024",
    customer_name: "TechCorp Solutions",
    payment_type: "bank_transfer",
  },
  {
    id: "ORD-2024-003",
    amount: 89.99,
    status: ["pending"],
    order_date: "09/20/2024",
    customer_name: "Ahmed Hassan",
    payment_type: "paypal",
  },
  {
    id: "ORD-2024-004",
    amount: 1200.5,
    status: ["processing"],
    order_date: "09/12/2024",
    customer_name: "Maria Gonzalez",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-005",
    amount: 0.0,
    status: ["cancelled"],
    order_date: "09/10/2024",
    customer_name: "Chen Wei",
    payment_type: "credit_card",
  },
  {
    id: "ORD-2024-006",
    amount: 8925.0,
    status: ["completed"],
    order_date: "08/28/2024",
    customer_name: "Green Valley Farms",
    payment_type: "check",
  },
  {
    id: "ORD-2024-007",
    amount: 45.99,
    status: ["shipped"],
    order_date: "09/21/2024",
    customer_name: "Jennifer Kim",
    payment_type: "cash",
  },
];

const createColumn = createColumnHelper<OrderData>();

const formatStatus = (value: StatusValue) =>
  chipsFormatter(
    value?.map((status) => ({
      label: status.charAt(0).toUpperCase() + status.slice(1),
      color:
        status === "pending"
          ? "#f59e0b"
          : status === "shipped"
            ? "#8b5cf6"
            : status === "processing"
              ? "#3b82f6"
              : status === "completed"
                ? "#10b981"
                : "#ef4444",
    })),
  );

const formatPaymentType = (value: PaymentType) => (
  <span>
    {value === "credit_card"
      ? "Credit Card"
      : value === "cash"
        ? "Cash"
        : value === "bank_transfer"
          ? "Bank Transfer"
          : value === "check"
            ? "Check"
            : value === "paypal"
              ? "PayPal"
              : value}
  </span>
);

const columns = [
  createColumn("id", {
    header: { label: "Order ID" },
    sortable: true,
    resizable: true,
    minWidth: 130,
  }),
  createColumn("customer_name", {
    header: { label: "Customer" },
    sortable: true,
    resizable: true,
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
    sortable: true,
  }),
  createColumn("status", {
    header: { label: "Status" },
    resizable: true,
    renderCell: (value: StatusValue) => formatStatus(value),
  }),
  createColumn("order_date", {
    header: { label: "Order Date" },
    sortable: true,
  }),
  createColumn("payment_type", {
    header: { label: "Payment Type" },
    renderCell: (value: PaymentType) => formatPaymentType(value),
    sortable: true,
    minWidth: 150,
  }),
];

function App() {
  return (
    <DataTable
      data={data}
      columns={columns}
      pagination={{
        rowsPerPage: 5,
        defaultPageIndex: 0,
        showCount: true,
      }}
    />
  );
}

export default App;
```

To add uncontrolled (client-side) pagination to a Data Table, set `pagination` to `true`:

```tsx theme={null}
<DataTable data={rowData} columns={columns} pagination />
```

Pass a config object for more fine-tuned control over the pagination:

```tsx theme={null}
<DataTable
  data={rowData}
  columns={columns}
  pagination={{
    rowsPerPage: 5,
    defaultPageIndex: 1,
    onPageChange: (pageIndex) =>
      console.log(`do something with the ${pageIndex}`),
    showCount: false,
  }}
/>
```

### Controlled pagination

To manually control the pagination state, use the `onPageChange` and `currentPageIndex` properties:

```tsx theme={null}
const [currentPageIndex, setCurrentPageIndex] = useState<number>(0);
return (
  <DataTable
    data={rowData}
    columns={columns}
    pagination={{
      currentPageIndex: 0,
      onPageChange: setCurrentPageIndex,
    }}
  />
);
```

### Rows per page selection

The pagination controls include a dropdown selector that allows users to change the number of rows displayed per page. This is enabled by default with options for 25, 50, and 100 rows per page.

To show the dropdown selector and the available options, use the `rowsPerPageOptions` and `onRowsPerPageChange` props to control the `rowsPerPage` state:

```tsx theme={null}
const [rowsPerPage, setRowsPerPage] = useState<number>(25);
return (
  <DataTable
    data={rowData}
    columns={columns}
    pagination={{
      rowsPerPage,
      onRowsPerPageChange: setRowsPerPage,
      rowsPerPageOptions: [5,10,15,20,25],
    }}
  />
);
```

> [!NOTE]
>
> When the user changes the rows per page selection, the page automatically resets to the first page if the current page index no longer exists.

### Server-side pagination

To prevent loading entire large data sets from a server, use the `loadPageData` property to control the data displayed in the table based on the current page. This can accept an array of row data, or a `Promise` that resolves an array of row data. While the `Promise` is pending, a loading spinner will be rendered in the data table.

The `totalRowCount` property should also be used to display the total number of rows, since this is normally determined by the length of the `data` array.

The `loadPageData` function receives a `sorting` parameter when a column is sorted, allowing the server to return data in the correct sort order. When sorting changes, the page index automatically resets to 0.

```tsx theme={null}
const [totalRowCount, setTotalRowCount] = useState<number>(0); // update this with the length of the data from the server

async function fetchPageData({
  pageIndex,
  pageSize,
  sorting,
}: {
  pageIndex: number;
  pageSize: number;
  sorting?: SortedColumn;
}) {
  // fetch data from the server for the given pageIndex, pageSize, and sorting
  // results are cached automatically per page index, so revisiting
  // a previously loaded page returns cached data without calling this again
  // this should return TableRow<TableColumns>[], similar to the data prop
}
return (
  <DataTable
    columns={columns}
    pagination={{
      loadPageData: fetchPageData,
      totalRowCount,
    }}
  />
);
```

### Cursor pagination / unknown total

Use cursor pagination when the server cannot provide a stable `totalRowCount`. Set `pagination.mode` to `"cursor"`, then control previous and next availability with `hasPrevPage` and `hasNextPage`. In cursor mode, DataTable renders previous and next controls only; page numbers and total count text are hidden because the total size is unknown.

`currentPageIndex` remains a navigation index for controlled state and direction detection. Cursor tokens stay owned by the implementor.

```tsx theme={null}
const [currentPageIndex, setCurrentPageIndex] = useState<number>(0);
const [hasNextPage, setHasNextPage] = useState<boolean>(true);
const [hasPrevPage, setHasPrevPage] = useState<boolean>(false);
const cursorRequestIdRef = useRef<number>(0);

async function fetchCursorPage({
  pageIndex,
  pageSize,
  sorting,
}: {
  pageIndex: number;
  pageSize: number;
  sorting?: SortedColumn;
}) {
  const requestId = cursorRequestIdRef.current + 1;
  cursorRequestIdRef.current = requestId;

  const query = new URLSearchParams({
    pageIndex: String(pageIndex),
    pageSize: String(pageSize),
  });

  if (sorting) {
    query.set("sortBy", sorting.id);
    query.set("sortDirection", sorting.desc ? "desc" : "asc");
  }

  const response = await fetch(`/api/orders/cursor?${query.toString()}`);
  const cursorPage: {
    rows: TableRow<TableColumns>[];
    hasNextPage: boolean;
    hasPrevPage: boolean;
  } = await response.json();

  if (cursorRequestIdRef.current === requestId) {
    setHasNextPage(cursorPage.hasNextPage);
    setHasPrevPage(cursorPage.hasPrevPage);
  }

  return cursorPage.rows;
}

<DataTable
  columns={columns}
  pagination={{
    mode: "cursor",
    currentPageIndex,
    onPageChange: setCurrentPageIndex,
    loadPageData: fetchCursorPage,
    hasNextPage,
    hasPrevPage,
  }}
/>
```

> [!NOTE]
>
> Cursor mode disables caching by default. Provide `cache` only when page identity is stable.

### Caching

`DataTable` caches `loadPageData` results by default. When a user navigates to a previously loaded page, the cached data displays instantly without calling `loadPageData` again or showing a loading spinner.

Configure caching behavior through the `cache` property on the pagination config:

```tsx theme={null}
// Disable caching
<DataTable
  columns={columns}
  pagination={{
    loadPageData: fetchPageData,
    totalRowCount,
    cache: { enabled: false },
  }}
/>

// Configure max cache size (default: 15 pages)
<DataTable
  columns={columns}
  pagination={{
    loadPageData: fetchPageData,
    totalRowCount,
    cache: { maxSize: 50 },
  }}
/>
```

Cursor mode disables caching by default. Provide `cache` only when page identity is stable.

#### Clearing the cache

Use a ref to imperatively clear the page data cache without refetching:

```tsx theme={null}
import { useRef } from "react";
import { DataTable, DataTableRef } from "@servicetitan/anvil2/beta";

const ExampleComponent = () => {
  const dataTableRef = useRef<DataTableRef>(null);

  const handleClearCache = () => {
    dataTableRef.current?.clearCache();
  };

  return (
    <>
      <DataTable
        ref={dataTableRef}
        columns={columns}
        pagination={{
          loadPageData: fetchPageData,
          totalRowCount,
        }}
      />
      <button onClick={handleClearCache}>Clear Cache</button>
    </>
  );
};
```

Call `refresh()` to clear the cache, refetch data, and reset to the first page. Use this when the underlying data has changed and the table needs to reflect the update:

```tsx theme={null}
const dataTableRef = useRef<DataTableRef>(null);

const handleDataChange = () => {
  dataTableRef.current?.refresh();
};
```

#### Automatic cache invalidation

The cache automatically clears when any of the following change:

* `refreshKey` — Use this to invalidate the cache when external filters or search parameters change. The page index resets to 0
* `rowsPerPage` — Cached data becomes invalid when the page size changes
* `refresh()` — Calling `refresh()` on the ref clears the cache and refetches from page 0

```tsx theme={null}
const [filters, setFilters] = useState({ status: "active" });

<DataTable
  columns={columns}
  pagination={{
    loadPageData: fetchPageData,
    totalRowCount,
    refreshKey: JSON.stringify(filters),
  }}
/>
```

> [!NOTE]
>
> Caching only applies when using `loadPageData` for server-side pagination. Client-side pagination with the `data` prop does not use this cache.

## Footers

Data tables can include footer rows, which can be defined in two ways:

1. With the `footerContent` option when defining a column. This method places the footer cell in the same column as the header and body cells according to the column definition.
2. With the `customFooter` prop on the `DataTable` component. This method creates footer rows with cells that have custom-defined col-spans, rather than aligning with the rest of the table content.

> [!NOTE]
>
> If the data table has a specific height or max-height, the footer columns will have sticky positioning when scrolling vertically.

### Default footers (`footerContent`)

The `footerContent` property can accept a `ReactNode` or `ReactNode[]`. If an array is given, each node will add a new footer row.

```tsx theme={null}
createColumn("amount_due", {
  header: { label: "Amount Due" },
  renderCell: currencyFormatter,
  // create two footer rows, one with the total amount due, and one with the average
  footerContent: [
    <span key="total">
      {currencyFormatter(rowData.reduce((acc, row) => acc + row.amount_due, 0))}
    </span>,
    <span key="average">
      {currencyFormatter(
        rowData.reduce((acc, row) => acc + row.amount_due, 0) / data.length,
      )}{" "}
      (avg)
    </span>,
  ]
}),
```

### Custom footers (`DataTable.customFooter`)

The `DataTable.customFooter` prop accepts an array of arrays, which can include objects that have `content: ReactNode` and `colSpan: number` properties. The `content` will render within the cell, and the `colSpan` will determine how many columns that cell covers.

data-table-custom-footer

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

```tsx lines expandable theme={null}
import {
  DataTable,
  createColumnHelper,
  currencyFormatter,
  type TableRow,
} from "@servicetitan/anvil2/beta";

type InvoiceData = {
  id: string;
  customer_name: string;
  service: string;
  amount: number;
  tax: number;
  total: number;
};

const data: TableRow<InvoiceData>[] = [
  {
    id: "INV-001",
    customer_name: "Jose Ramirez",
    service: "HVAC Repair",
    amount: 450.0,
    tax: 36.0,
    total: 486.0,
  },
  {
    id: "INV-002",
    customer_name: "Sophia Rodriguez",
    service: "Plumbing Install",
    amount: 1200.0,
    tax: 96.0,
    total: 1296.0,
  },
  {
    id: "INV-003",
    customer_name: "TechCorp Solutions",
    service: "Electrical Inspection",
    amount: 275.0,
    tax: 22.0,
    total: 297.0,
  },
  {
    id: "INV-004",
    customer_name: "Ahmed Hassan",
    service: "HVAC Maintenance",
    amount: 350.0,
    tax: 28.0,
    total: 378.0,
  },
];

const createColumn = createColumnHelper<InvoiceData>();

const columns = [
  createColumn("id", {
    header: { label: "Invoice ID" },
    minWidth: 100,
  }),
  createColumn("customer_name", {
    header: { label: "Customer" },
    minWidth: 150,
  }),
  createColumn("service", {
    header: { label: "Service" },
    minWidth: 150,
  }),
  createColumn("amount", {
    header: { label: "Amount" },
    renderCell: (value: InvoiceData["amount"]) => currencyFormatter(value),
    minWidth: 100,
  }),
  createColumn("tax", {
    header: { label: "Tax" },
    renderCell: (value: InvoiceData["tax"]) => currencyFormatter(value),
    minWidth: 80,
  }),
  createColumn("total", {
    header: { label: "Total" },
    renderCell: (value: InvoiceData["total"]) => currencyFormatter(value),
    minWidth: 100,
  }),
];

const totalAmount = data.reduce((acc, row) => acc + row.amount, 0);
const totalTax = data.reduce((acc, row) => acc + row.tax, 0);
const grandTotal = data.reduce((acc, row) => acc + row.total, 0);

function App() {
  return (
    <DataTable
      data={data}
      columns={columns}
      customFooter={[
        [
          { content: <b>Totals</b>, colSpan: 3 },
          { content: currencyFormatter(totalAmount), colSpan: 1 },
          { content: currencyFormatter(totalTax), colSpan: 1 },
          { content: <b>{currencyFormatter(grandTotal)}</b>, colSpan: 1 },
        ],
      ]}
    />
  );
}

export default App;
```

## Optimizing re-renders with `getRowVersion`

By default, every row in a `DataTable` re-renders whenever the parent component re-renders. For most tables this is fine, but on large tables or tables whose parent re-renders often, you can opt each row into memoization by providing a `getRowVersion` function.

`getRowVersion` receives a row and returns a value that signals "this row has changed." Rows only re-render when that value differs between renders (or when table-managed state like selection or expansion changes). If a row's cell rendering depends on state outside the row object (for example, a highlighted row ID stored elsewhere), fold that state into the returned value so memoization stays correct.

```tsx theme={null}
<DataTable
  data={data}
  columns={columns}
  getRowVersion={(row) => `${row.updatedAt}-${highlightedId}`}
/>
```

If `getRowVersion` is not provided, rows behave exactly as before — no memoization, no surprises.

## React Accessibility

* The DataTable implements proper ARIA attributes for table structure and interactive elements
* Sortable column headers are keyboard accessible and announce sort state to screen readers
* Row selection checkboxes include appropriate `aria-label` attributes
* Expand/collapse buttons include `aria-label`, `aria-expanded`, and `aria-controls` attributes
* The component uses proper semantic HTML table elements (`table`, `thead`, `tbody`, `tr`, `th`, `td`)
* Loading states are announced to screen readers using `aria-live` regions
* Focus management ensures keyboard navigation follows expected patterns
* Editable cells are keyboard accessible and announce their editable state

## Feedback

The DataTable is in beta as the team gathers feedback on the API and visual design. Once stabilized, it will be promoted to the stable layer. If you encounter issues or have suggestions, reach out to the Anvil team in [#ask-designsystem](https://servicetitan.enterprise.slack.com/archives/CBSRGHTRS) on Slack.

**Testing**

## Testing

The table'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 table. It is forwarded to the table container:

```tsx theme={null}
<DataTable data-cy="orders-table" data={data} columns={columns} />
```

```ts theme={null}
// Target a specific row's select checkbox
cy.get('[data-cy="orders-table"] [data-row-id="row-1"] [data-anv-part="select-checkbox"]').click();

// Target a column's sort control
cy.get('[data-cy="orders-table"] [data-anv-column-id="amount"][data-anv-part="sort-toggle"]').click();
```

| `data-anv-part` | Element |
| - | - |
| `row` | Body row (`role="row"`) |
| `cell` | Body cell (`role="gridcell"`) |
| `sort-toggle` | Sortable header cell |
| `select-checkbox` | Per-row selection checkbox |
| `select-all-checkbox` | Header select-all checkbox |
| `expand-button` | Per-row expand/collapse button |
| `expand-all-button` | Header expand-all button |
| `resize-handle` | Column resize handle |
| `bulk-action-bar` | Bulk action bar |
| `bulk-select-all-button` | "Select all N items" button |
| `bulk-cancel-button` | Bulk action bar "Cancel" button |
| `bulk-action-button` | Primary or secondary bulk action button |
| `bulk-actions-menu-button` | Primary bulk action that opens a menu |
| `bulk-action-menu-item` | Bulk action menu item |

Narrow repeated parts with existing discriminators: `data-row-id` on rows and `data-anv-column-id` on cells/headers.

The bulk action bar renders above the table, outside the element that receives your `data-*` attributes, so the `bulk-*` parts can't be scoped with `[data-cy="orders-table"]`. Query them directly, or scope them by a parent element you own when the page has more than one table:

```ts theme={null}
cy.get('[data-anv-part="bulk-cancel-button"]').click();
cy.get('[data-cy="orders-panel"] [data-anv-part="bulk-action-button"]').click();
```

Offset pagination uses the shared [Pagination](https://anvil.servicetitan.com/docs/components/pagination/code) parts (`prev-page-button`, `next-page-button`, `page-button`, and so on). Cursor pagination reuses the same `prev-page-button` / `next-page-button` / items-per-page parts.

**DataTable Props**

```tsx theme={null}
<DataTable
  data={data}
  columns={columns}
  isSelectable
  pagination={{
    rowsPerPage: 25,
    defaultPageIndex: 0,
    showCount: true,
  }}
  onSort={(sortedColumn) => console.log(sortedColumn)}
  onSelectRow={(selectedIds) => console.log(selectedIds)}
/>
```

## `DataTable` Props

**columns** `ColumnDef\<T\>[]` *required*

Column definitions created using the `createColumnHelper` factory function. Each column definition specifies how data should be displayed and what features are enabled.

**data** `TableRow\<T\>[] | Promise\<TableRow\<T\>[]\>` *required*

The data for the table. Must be an array of objects where each object has an `id` property. Can be a Promise for async data loading, in which case a loading spinner displays until the data resolves.

**bulkActions** `DataTableBulkActions\<T\>`

Actions shown in a bar above the table while rows are selected. Requires `isSelectable`. Takes `primary` (a button, or a menu with `items`), `secondary` (one button before the primary), `selectAll` (shows "Select all N items" with offset pagination), and `selectableRowCount` (the "Select all" count for `loadPageData` tables). Each action's `label` is a string or a function of the selected count, and its `onClick` receives a `DataTableBulkSelection`; return `false` to keep the rows selected.

**columnEditing** `DataTableColumnEditing`

Adds an Edit Columns button at the end of the toolbar. Its drawer lets users reorder, hide, and pin columns, and reports the result through `onLayoutChange` when they select Apply. `DataTable` applies `layout` to `columns` and doesn't store it. Takes `layout` (`DataTableColumnPref[]`, required), `onLayoutChange` (`(layout: DataTableColumnPref[]) => void`, required), and an optional `hideButton` (`boolean`, default `false`) that removes the toolbar button while the layout still applies. See the Column Editing tab.

**customFooter** `CustomTableFooterCellProps[][]`

Custom footer content for the table. Each array represents a footer row, with objects specifying `content` and `colSpan`. This footer is overridden by the `footerContent` property of individual columns.

**defaultExpandedRowIds** `"all" | TableRow\<T\>["id"][]`

The default expanded row IDs for uncontrolled expansion. Use `"all"` to expand all rows, or provide an array of specific row IDs to expand.

**defaultSelectedRowIds** `TableRow\<T\>["id"][]`

The default selected row IDs for uncontrolled selection.

**defaultSortedColumn** `SortedColumn`

The default sorted column for uncontrolled sorting. Object with `id` (column identifier) and `desc` (boolean for descending order).

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

Disables row virtualization. By default, tables with 50 or more rows virtualize their body to keep DOM size bounded; set this to `true` to render every row in the DOM regardless of count. Intended as an escape hatch for rollout safety and for flows that need every row mounted (for example, find-in-page, or certain print and export paths).

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

Disables the "expand all" action in the table header. When `true`, users can only expand individual rows.

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

Disables the "select all" action in the table header. When `true`, users can only select individual rows.

**emptyState** `DataTableEmptyState`

Configuration for the empty state displayed when the table has no data. Accepts an object with optional `svg` (an SVG component), `content` (ReactNode), and `includeAddRow` (boolean, repeats the `rowAdd` button in the empty state). Only rendered when data is empty and not loading.

**emptyCellContent** `string`

Custom string to display when a cell value is empty (`null`, `undefined`, or empty string). Overrides the default em dash (—) for all columns. Individual columns can further override this with their own `emptyCellContent` property in the column definition.

**expandedRowIds** `"all" | TableRow\<T\>["id"][]`

The controlled expanded row IDs. Use `"all"` to expand all rows, or provide an array of specific row IDs. Must be used with `onExpandRow` for controlled expansion.

**filters** `DataTableFilters`

Filter bar rendered above the table. `DataTable` does not filter rows itself; apply changes from `onChange` to `data` or to your `loadPageData` request.

* `items` (`Filter[]`, required): filter definitions. Filters marked `drawerOnly` appear only in the filter drawer. Memoize the array, because a new identity re-syncs the filter bar.
* `onChange` (`(filters: Filter[]) => void`, required): receives the full filter list after a filter commit. A change also returns a paginated table to its first page. With `pagination.loadPageData`, it clears the page cache and reloads so the loader sees the new filters.
* `controlled` (`boolean`, default `false`): delays inline commits for filters that have an Apply step. Filters without one still commit immediately, and the filter drawer always batches changes until the user applies them.
* `size` (`"xsmall" | "small" | "medium" | "large"`, default `"medium"`): size of the filter bar controls.

**getRowVersion** `(row: T) =\> unknown`

Opt-in row memoization signal. When provided, each row is memoized and only re-renders when the returned value differs from the previous render, or when table-managed row state (selection, expansion, etc.) changes. When omitted, every row re-renders whenever the `DataTable` re-renders.

Bake anything outside the row's own data that affects cell rendering into the returned value (for example `` `${row.updatedAt}-${highlightedId}` ``), otherwise cells may appear stale when external state changes.

**isSelectable** `boolean | ((row: T) =\> boolean)` (default: false)

Whether rows are selectable with checkboxes. Can be a boolean to enable selection for all rows, or a function to conditionally enable selection based on row data.

**onExpandRow** `(expandedRowIds: 'all' | TableRow\<T\>['id'][]) =\> void`

Callback when row expansion state changes. Receives either `"all"` when all rows are expanded, or an array of expanded row IDs.

**onSelectRow** `(selectedRowIds: TableRow\<T\>['id'][]) =\> void`

Callback when row selection state changes. Receives an array of selected row IDs.

**onClickRow** `(rowId: TableRow\<T\>['id']) =\> void`

Callback when a row is clicked or activated from the keyboard (Enter while a non-interactive cell is focused). Receives the clicked row's ID. The selection checkbox, editable cells, other interactive cell content (links, buttons, menus), and read-only rows do not trigger it. Custom cell content with its own click behavior must use a real interactive element or interactive role to be excluded; a handler on a bare `div`/`span` still fires `onClickRow` unless it calls `event.stopPropagation()`.

**onSort** `(sortedColumn: SortedColumn | undefined) =\> void`

Callback when column sorting state changes. Receives the sorted column object with `id` and `desc` properties, or `undefined` when sorting is cleared.

**pagination** `boolean | DataTablePaginationConfig\<T\>`

Pagination configuration. When `true`, enables pagination with default settings (25 items per page). When `false` or `undefined`, no pagination is applied and all data is shown. Provide a `DataTablePaginationConfig` object for custom configuration.

**readOnlyRowIds** `TableRow\<T\>["id"][]`

Row IDs that should render as read-only. Read-only rows gray their plain-text cell content, show a not-allowed cursor on the row surface, and render the selection checkbox as disabled and read-only. Links, chips, and custom cell content are unaffected. Pre-seeded `selectedRowIds` is still honored — this prop only gates user interaction (checkbox toggling), not the visual selection state.

**rowAdd** `DataTableRowAdd`

Add row button in a toolbar below the table. See [Adding rows](#adding-rows).

**selectedRowIds** `TableRow\<T\>["id"][]`

The controlled selected row IDs. Must be used with `onSelectRow` for controlled selection.

**sortedColumn** `SortedColumn`

The controlled sorted column. Object with `id` (column identifier) and `desc` (boolean for descending order). Must be used with `onSort` for controlled sorting.

**tools** `DataTableTools`

Toolbar rendered to the right of the filter bar, such as Export. Works with or without `filters`. Takes `actions` (`DataTableToolbarAction[]`, required) and an optional `size` (`"xsmall" | "small" | "medium" | "large"`, default `"medium"`) for the toolbar controls. Only Anvil2 toolbar buttons are accepted; arbitrary nodes are not.

**Toolbar Action (DataTableToolbarAction)**

```tsx theme={null}
const actions: DataTableToolbarAction[] = [
  { id: "export", children: "Export", onClick: handleExport },
  { id: "print", icon: PrintIcon, "aria-label": "Print", onClick: handlePrint },
];
```

## `DataTableToolbarAction` Properties

`DataTableToolbarAction` is the props of a toolbar button plus a required `id`. Pass an array of these as `tools.actions`.

**id** `string` *required*

Stable identity for the button. Used as the React key and not forwarded to the DOM.

**aria-label** `string`

Accessible name. Required when the button has no `children`.

**appearance** `"ghost" | "primary"` (default: "ghost")

Visual emphasis within the toolbar.

**children** `ReactNode`

Text label for the button. Provide either `children` or an `icon` with an `aria-label`.

**icon** `Svg`

Icon rendered in the button.

**onClick** `(event: MouseEvent\<HTMLButtonElement\>) =\> void`

Callback when the button is clicked. Other `Button` props, such as `disabled`, are also accepted.

**Column Editing (DataTableColumnEditing)**

```tsx theme={null}
const [layout, setLayout] = useState<DataTableColumnPref[]>([]);

<DataTable
  data={data}
  columns={columns}
  columnEditing={{ layout, onLayoutChange: setLayout }}
/>
```

## `DataTableColumnEditing` Properties

**layout** `DataTableColumnPref[]` *required*

The current order, visibility, and pinning. Ids that match no column are ignored, and columns missing from the list are added as visible.

**onLayoutChange** `(layout: DataTableColumnPref[]) =\> void` *required*

Fires with the full layout when the user applies their edits, and with the layout implied by `columns` when they reset to defaults.

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

Removes the toolbar button while the layout still applies. Use it when a page opens one `DataTableEditColumnsDrawer` for several tables.

## `DataTableColumnPref` Properties

**id** `string` *required*

The `id` of a top-level column in `columns`.

**pinned** `boolean` *required*

Pins the column to the left. A hidden column is never pinned.

**visible** `boolean` *required*

Whether the column is shown.

## `DataTableEditColumnsDrawer` Props

```tsx theme={null}
<DataTableEditColumnsDrawer
  open={open}
  onClose={() => setOpen(false)}
  sections={sections}
/>
```

**onClose** `() =\> void` *required*

Fires when the drawer closes, after Apply, after Reset Defaults, or after the user confirms discarding their edits.

**open** `boolean` *required*

Whether the drawer is open.

**sections** `DataTableEditColumnsSection[]` *required*

One section for each table. Apply reports only the sections the user changed, and Reset Defaults resets every section. Memoize the array.

## `DataTableEditColumnsSection` Properties

**columns** `ColumnDef\<T\>[]` *required*

The same `columns` the table receives.

**layout** `DataTableColumnPref[]` *required*

The table's current layout.

**onLayoutChange** `(layout: DataTableColumnPref[]) =\> void` *required*

Fires with the new layout on Apply, or with the default layout on Reset Defaults.

**title** `string`

Heading shown above the section when the drawer has more than one.

**Row Add (DataTableRowAdd)**

```tsx theme={null}
const rowAdd: DataTableRowAdd = {
  onAddRow: () => setDialogOpen(true),
};
```

## `DataTableRowAdd` Properties

**onAddRow** `() =\> void` *required*

Fires when the Add button is clicked. `DataTable` does not know when your flow finishes, so update `data` when it does. For a table that loads rows with `pagination.loadPageData`, call `refresh()` on the table's ref instead.

**addRowLabel** `string` (default: "Add row")

Label of the Add button.

**disabled** `boolean`

Disables the Add button.

**loading** `boolean`

Shows the Add button as busy, such as while an external check runs.

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

Size of the toolbar controls.

**Column Definition (ColumnDef)**

```tsx theme={null}
const columnHelper = createColumnHelper<DataType>();

const column = columnHelper("fieldName", {
  header: {
    label: "Column Header",
    required: true,
    moreInfo: "Shown in a tooltip trigger.",
  },
  sortable: true,
  resizable: true,
  align: "start",
  minWidth: 150,
  maxWidth: 400,
  renderCell: (value, { row, depth }) => <CustomComponent value={value} />,
});
```

## Column Definition Parameters

Columns are created using the `createColumnHelper` factory function, which provides type-safe column definitions.

**header** `\{ label: string; required?: boolean; moreInfo?: ReactNode \}` *required*

Header configuration object. `label` sets the visible header text, `required` adds the shared required affordance, and `moreInfo` renders a keyboard-accessible tooltip trigger.

**header.label** `string` *required*

The visible label text displayed in the column header.

**header.required** `boolean`

Whether the header should display the shared required indicator.

**header.moreInfo** `ReactNode`

Additional contextual content shown from the header more-info trigger.

**Column Definition Options (below)** `object` *required*

### Column Definition Options

**align** `"start" | "center" | "end"`

The horizontal alignment of the header and cell content within the column.

**alwaysVisible** `boolean`

Keeps the column out of the Edit Columns drawer, so users can't hide, reorder, or pin it. Only applies when the table has `columnEditing`.

**columns** `ColumnDef\<T\>[]`

Nested columns for creating grouped column headers. Used when creating column groups with the `{ group: string }` syntax in `createColumnHelper`.

**editMode** `"text" | "select" | "multiselect"`

The edit mode for cells in this column. When set, cells become editable inline. Must be used with `onChange` callback.

**emptyCellContent** `string`

Custom string to display when a cell value in this column is empty (`null`, `undefined`, or empty string). Overrides both the default em dash (—) and any table-level `emptyCellContent` set on the `DataTable` component.

**footerContent** `ReactNode | ReactNode[]`

The content displayed in the footer cell for this column. When an array is provided, multiple footer rows are created, one for each array element.

**maxWidth** `number`

The maximum width of the column in pixels. Useful for constraining column expansion.

**minWidth** `number`

The minimum width of the column in pixels. Useful for ensuring minimum readability.

**onChange** `(value: T[keyof T], rowId: string) =\> void`

Callback function called when the value of an editable cell is saved. Receives the new value and the row ID. Must be used with `editMode`.

**options** `\{ value: T[keyof T]; label: string \}[]`

The options for select or multiselect edit modes. Each option has a `value` (the data value) and `label` (display text). Required when `editMode` is `"select"` or `"multiselect"`.

**pinned** `"left" | "right"`

The pinning location for the column. Pinned columns remain visible during horizontal scrolling.

**renderCell** `(value: T[keyof T], \{ row, depth \}: \{ row?: TableRow\<T\>; depth?: number \}) =\> ReactNode`

Custom rendering function for cell content. Receives the cell value, the full row data object, and the row depth (0 for top-level rows, 1+ for nested rows). Return a React node to customize the cell display.

**resizable** `boolean`

Whether the column can be resized by dragging the column border. When enabled, users can adjust column width within the `minWidth` and `maxWidth` constraints.

**sortable** `boolean | ((valueA: T[keyof T], valueB: T[keyof T]) =\> number)`

Whether the column is sortable. When `true`, uses default comparison. When a function is provided, uses custom sorting logic (similar to `Array.sort` comparator).

**Pagination Configuration**

```tsx theme={null}
<DataTable
  data={data}
  columns={columns}
  pagination={{
    rowsPerPage: 25,
    currentPageIndex: 0,
    defaultPageIndex: 0,
    totalRowCount: 1000,
    refreshKey: "some-filter-key",
    cache: { enabled: true, maxSize: 15 },
    showCount: true,
    onPageChange: (pageIndex) => console.log(pageIndex),
    loadPageData: async ({ pageIndex, pageSize, sorting }) => {
      const url = new URL("/api", window.location.origin);
      url.searchParams.set("page", String(pageIndex));
      url.searchParams.set("size", String(pageSize));
      if (sorting) {
        url.searchParams.set("sortBy", sorting.id);
        url.searchParams.set("sortDir", sorting.desc ? "desc" : "asc");
      }
      const response = await fetch(url);
      return response.json();
    },
  }}
/>
```

## `DataTablePaginationConfig` Properties

**cache** `DataTableCacheOptions`

Configuration for caching `loadPageData` results per page index:

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

**currentPageIndex** `number`

Current page index (0-based) for controlled pagination mode. Must be used with `onPageChange` for controlled pagination.

**defaultPageIndex** `number` (default: 0)

Default page index for uncontrolled pagination mode (0-based).

**loadPageData** `(\{ pageIndex, pageSize, sorting \}) =\> TableRow\<T\>[] | Promise\<TableRow\<T\>[]\>`

Function to load data for the current page. When provided, enables server-side pagination where the component doesn't manage the full dataset. Receives `pageIndex` (0-based), `pageSize`, and an optional `sorting` object (`{ id: string; desc: boolean }`) when a column is sorted. Use with `totalRowCount` to display accurate pagination controls.

**onPageChange** `(pageIndex: number) =\> void`

Callback when the page changes. Receives the new page index (0-based).

**onRowsPerPageChange** `(rowsPerPage: number) =\> void`

Callback when the rows per page selection changes. Receives the new rows per page value.

**rowsPerPage** `number` (default: 25)

Number of rows displayed per page.

**refreshKey** `string | number`

Key that triggers a data refetch and cache clear when its value changes. The page index automatically resets to 0 on change. Use this to force a refetch when external filters or search parameters change.

**rowsPerPageOptions** `number[]` (default: \[25, 50, 100])

Available options for rows per page selection. The dropdown selector will display these values as options for users to choose from.

**showCount** `boolean` (default: true)

Whether to display the item count information in the pagination controls (e.g., "Showing 1-25 of 100").

**totalRowCount** `number`

Total number of rows across all pages. Falls back to `data.length` if not provided. Useful for server-side pagination scenarios where `totalRowCount` may differ from the data array length.


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