# Table

Package: @commenda/pathfinder@0.1.0
Page: /docs/components/table
Figma: https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=428-2

```tsx
import { Table } from '@commenda/pathfinder/table';

export const TableDefault = () => (
  <Table
    label="Recent invoices"
    rows={[
      { id: 'INV-001', customer: 'Meridian GmbH', amount: 2400 },
      { id: 'INV-002', customer: 'Meridian US Inc.', amount: 1800 },
      { id: 'INV-003', customer: 'Meridian UK Ltd.', amount: 950 },
    ]}
    rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.id }}
    columns={[
      { id: 'invoice', header: 'Invoice', value: (row) => row.id },
      {
        id: 'customer',
        header: 'Customer',
        width: 240,
        value: (row) => row.customer,
      },
      {
        id: 'amount',
        header: 'Amount (USD)',
        align: 'end',
        value: (row) => row.amount,
      },
    ]}
  />
);
```


Use Table for records people find, open and act on. Start with `rows`, typed `columns`, and stable row identities, then add the features your screen needs. Use [DataGrid](/docs/components/data-grid) for financial matrices with grouped headers and keyboard cell navigation.

## Headers and alignment [#headers-and-alignment]


```tsx
import { Table, TableNumberCell } from '@commenda/pathfinder/table';

const records = [
  {
    id: 'INV-001',
    customer: 'Meridian GmbH',
    entity: 'Germany',
    owner: 'Lucas Moreau',
    status: 'Paid',
    amount: 4800,
  },
  {
    id: 'INV-002',
    customer: 'Meridian US Inc.',
    entity: 'United States',
    owner: 'Priya Shah',
    status: 'Open',
    amount: 2150,
  },
  {
    id: 'INV-003',
    customer: 'Meridian UK Ltd.',
    entity: 'United Kingdom',
    owner: 'Aisha Khan',
    status: 'Open',
    amount: 3200,
  },
];

export const TableLayoutHeaders = () => (
  <Table
    label="Header alignment"
    rows={records}
    rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.id }}
    selectionConfig={{}}
    sortingConfig={{ defaultValue: { columnId: 'amount', direction: 'asc' } }}
    columns={[
      { id: 'id', header: 'Invoice', value: (row) => row.id, sortable: true },
      {
        id: 'status',
        header: 'Status',
        align: 'center',
        value: (row) => row.status,
        sortable: true,
      },
      {
        id: 'amount',
        header: 'Amount',
        description: 'Invoice total in USD, including tax.',
        align: 'end',
        value: (row) => row.amount,
        sortable: true,
        cell: (row) => <TableNumberCell value={row.amount} unit="USD" />,
      },
    ]}
  />
);
```


Headers are 36px high with 12px medium labels and 12px icons. Hover or focus an unsorted header to reveal the neutral sort indicator; the active column keeps its direction visible. Click to cycle sorting. End-aligned numeric columns place sorting before the label. Supply `description` for a separate, keyboard-accessible information tooltip. Selection headers use the same horizontal inset as row checkboxes.

## Usage [#usage]

```tsx
'use client';

import { Table } from '@commenda/pathfinder/table';

const invoices = [
  { id: 'inv-001', number: 'INV-001', customer: 'Meridian UK Ltd' },
  { id: 'inv-002', number: 'INV-002', customer: 'Meridian US Inc.' },
];

export default function InvoicesTable() {
  return (
    <Table
      label="Invoices"
      rows={invoices}
      rowConfig={{ getId: (invoice) => invoice.id }}
      columns={[
        { id: 'number', header: 'Invoice', value: (invoice) => invoice.number },
        {
          id: 'customer',
          header: 'Customer',
          value: (invoice) => invoice.customer,
        },
      ]}
    />
  );
}
```

Import `@commenda/pathfinder/styles.css` once in your app layout. Pathfinder ships its own compiled styles. Open **Code** on any example for the complete, formatted implementation; the snippet comes from the same source as its preview.

Columns define a stable `id`, a visible `header`, and a `value(row)` accessor. Add `cell(row)` when rendering richer content. Keep the accessor when sorting or searching should use the underlying value rather than its formatted label. `rowConfig.getId` must return a stable record ID, not an array index.

## Sorting [#sorting]


```tsx
import { useState } from 'react';

import {
  Table,
  TableNumberCell,
  type TableSort,
} from '@commenda/pathfinder/table';

const invoices = [
  { id: 'INV-001', customer: 'Meridian GmbH', status: 'Paid', amount: 2400 },
  {
    id: 'INV-002',
    customer: 'Meridian US Inc.',
    status: 'Pending',
    amount: 1800,
  },
  {
    id: 'INV-003',
    customer: 'Meridian UK Ltd.',
    status: 'Overdue',
    amount: 950,
  },
  {
    id: 'INV-004',
    customer: 'Meridian Singapore',
    status: 'Pending',
    amount: 3200,
  },
];

export const TableInteractionsDefault = () => {
  const [sort, setSort] = useState<TableSort | null>({
    columnId: 'amount',
    direction: 'desc',
  });

  return (
    <div className="pf:w-full pf:space-y-md">
      <Table
        label="Sortable invoices"
        rows={invoices}
        rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.id }}
        columns={[
          {
            id: 'invoice',
            header: 'Invoice',
            value: (row) => row.id,
            sortable: true,
          },
          {
            id: 'customer',
            header: 'Customer',
            width: 240,
            value: (row) => row.customer,
            sortable: true,
          },
          {
            id: 'amount',
            header: 'Amount',
            align: 'end',
            value: (row) => row.amount,
            sortable: true,
            cell: (row) => (
              <TableNumberCell
                value={row.amount}
                unit="USD"
                format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }}
              />
            ),
          },
        ]}
        sortingConfig={{ value: sort, onValueChange: setSort }}
      />
      <p role="status" className="pf:text-label-12 pf:text-soft-pearl-800">
        {sort
          ? `Sorted by ${sort.columnId}, ${sort.direction === 'asc' ? 'ascending' : 'descending'}`
          : 'Original order'}
      </p>
    </div>
  );
};
```


Click Amount to cycle descending, unsorted and ascending order. The initial sort is controlled by `sortingConfig`; the status below the example reflects the current value. Numeric accessors sort numerically even when the cell formats money. Local sorting runs before pagination.

## Search and filters [#search-and-filters]


```tsx
import { useState } from 'react';

import { Table, TableCell } from '@commenda/pathfinder/table';

const invoices = [
  { id: 'INV-001', customer: 'Meridian GmbH', status: 'Paid', amount: 2400 },
  {
    id: 'INV-002',
    customer: 'Meridian US Inc.',
    status: 'Pending',
    amount: 1800,
  },
  {
    id: 'INV-003',
    customer: 'Meridian UK Ltd.',
    status: 'Overdue',
    amount: 950,
  },
  {
    id: 'INV-004',
    customer: 'Meridian Singapore',
    status: 'Pending',
    amount: 3200,
  },
];

export const TableInteractionsFiltering = () => {
  const [query, setQuery] = useState('');
  const [filters, setFilters] = useState<Record<string, readonly string[]>>({});

  return (
    <Table
      label="Searchable invoices"
      rows={invoices}
      rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.id }}
      columns={[
        { id: 'invoice', header: 'Invoice', value: (row) => row.id },
        {
          id: 'customer',
          header: 'Customer',
          width: 240,
          value: (row) => row.customer,
        },
        {
          id: 'status',
          header: 'Status',
          align: 'end',
          value: (row) => row.status,
          cell: (row) => (
            <TableCell
              type="badge"
              items={[
                {
                  label: row.status,
                  tone:
                    row.status === 'Paid'
                      ? 'success'
                      : row.status === 'Overdue'
                        ? 'error'
                        : 'neutral',
                },
              ]}
            />
          ),
        },
      ]}
      searchConfig={{
        value: query,
        onValueChange: setQuery,
        getText: (row) => `${row.id} ${row.customer}`,
      }}
      filterConfig={{
        value: filters,
        onValueChange: setFilters,
        filters: [
          {
            id: 'status',
            label: 'Status',
            options: ['Paid', 'Pending', 'Overdue'].map((value) => ({
              value,
              label: value,
            })),
            matches: (row, values) => values.includes(row.status),
          },
        ],
      }}
    />
  );
};
```


Open search and enter **Meridian UK**, or choose **Pending** from Status. Search and filters combine. Search opens in place; tools stay together and excess filter buttons move into +N. A query with no matches offers Clear filters. This example supplies `getText` explicitly so invoice numbers and customer names are searchable.

For a local collection, `matches(row, values)` defines filter membership. In remote mode the server applies it; use controlled search/filter state as request inputs. Reset the page when an externally controlled filter changes.

## Single-choice filters [#single-choice-filters]


```tsx
import { useState } from 'react';

import { Table } from '@commenda/pathfinder/table';

export const TableFiltersSingle = () => {
  const [value, setValue] = useState<Record<string, readonly string[]>>({});

  return (
    <Table
      label="Single status filter"
      rows={[
        { id: 'INV-001', status: 'Paid' },
        { id: 'INV-002', status: 'Open' },
        { id: 'INV-003', status: 'Overdue' },
      ]}
      columns={[
        { id: 'id', header: 'Invoice', value: (row) => row.id },
        { id: 'status', header: 'Status', value: (row) => row.status },
      ]}
      rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.id }}
      filterConfig={{
        value,
        onValueChange: setValue,
        filters: [
          {
            id: 'status',
            label: 'Status',
            type: 'single',
            options: ['Paid', 'Open', 'Overdue'].map((label) => ({
              value: label,
              label,
            })),
            getValue: (row) => row.status,
          },
        ],
      }}
    />
  );
};
```


Choose a status. Single choice replaces the previous selection, shows a checkmark and closes its menu. Clear resets that field. The selected value becomes an editable chip below the toolbar.

## Complex filters [#complex-filters]


```tsx
import { useState } from 'react';

import { DEFlag } from '@commenda/pathfinder/flags/DEFlag';
import { USFlag } from '@commenda/pathfinder/flags/USFlag';
import { Building07Icon } from '@commenda/pathfinder/icons/Building07Icon';
import { CalendarIcon } from '@commenda/pathfinder/icons/CalendarIcon';
import { RefreshCw05Icon } from '@commenda/pathfinder/icons/RefreshCw05Icon';
import {
  Table,
  TableCell,
  type TableFilterOperator,
  TableNumberCell,
} from '@commenda/pathfinder/table';

export const TableFiltersDefault = () => {
  const [value, setValue] = useState<Record<string, readonly string[]>>({
    status: ['Overdue', 'Due soon'],
  });
  const [operators, setOperators] = useState<
    Readonly<Record<string, TableFilterOperator>>
  >({});
  const [notice, setNotice] = useState('');
  const rows = [
    {
      id: '1',
      filing: 'VAT return Q3',
      entity: 'DE',
      status: 'Filed',
      due: '2026-10-07',
      amount: 0,
      owner: 'Lucas Moreau',
    },
    {
      id: '2',
      filing: 'Trade tax return',
      entity: 'DE',
      status: 'In review',
      due: '2026-10-30',
      amount: 180,
      owner: 'Priya Shah',
    },
    {
      id: '3',
      filing: 'Annual accounts',
      entity: 'DE',
      status: 'Due soon',
      due: '2026-11-14',
      amount: 95,
      owner: 'Aisha Khan',
    },
    {
      id: '4',
      filing: 'Delaware annual report',
      entity: 'US',
      status: 'Overdue',
      due: '2026-10-01',
      amount: 450,
      owner: 'Jun Tan',
    },
    {
      id: '5',
      filing: 'BOI report update',
      entity: 'US',
      status: 'Due soon',
      due: '2026-11-14',
      amount: 0,
      owner: 'Priya Shah',
    },
  ];

  return (
    <div className="pf:w-full pf:min-w-0 pf:space-y-lg">
      <Table
        label="Filtered filings"
        rows={rows}
        rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.filing }}
        searchConfig={{
          getText: (row) => `${row.filing} ${row.owner}`,
          placeholder: 'Search filings',
        }}
        columns={[
          {
            id: 'filing',
            header: 'Filing',
            width: 240,
            value: (row) => row.filing,
          },
          {
            id: 'entity',
            header: 'Entity',
            width: 180,
            cell: (row) => (
              <TableCell
                type="entity"
                label={row.entity === 'DE' ? 'Acme GmbH' : 'Acme Inc.'}
                icon={
                  row.entity === 'DE' ? (
                    <DEFlag size={16} />
                  ) : (
                    <USFlag size={16} />
                  )
                }
              />
            ),
          },
          {
            id: 'status',
            header: 'Status',
            width: 130,
            cell: (row) => (
              <TableCell
                type="badge"
                items={[
                  {
                    label: row.status,
                    tone:
                      row.status === 'Overdue'
                        ? 'error'
                        : row.status === 'Filed'
                          ? 'success'
                          : 'warning',
                  },
                ]}
              />
            ),
          },
          { id: 'due', header: 'Due', width: 140, value: (row) => row.due },
          {
            id: 'amount',
            header: 'Amount',
            width: 140,
            align: 'end',
            cell: (row) => <TableNumberCell value={row.amount} unit="USD" />,
          },
        ]}
        filterConfig={{
          value,
          onValueChange: setValue,
          operators: { value: operators, onValueChange: setOperators },
          filters: [
            {
              id: 'status',
              label: 'Status',
              icon: <RefreshCw05Icon size={16} />,
              options: ['Overdue', 'Due soon', 'In review', 'Filed'].map(
                (label) => ({ value: label, label })
              ),
              getValue: (row) => row.status,
            },
            {
              id: 'entity',
              label: 'Entity',
              icon: <Building07Icon size={16} />,
              searchable: true,
              options: [
                {
                  value: 'DE',
                  label: 'Acme GmbH',
                  leading: <DEFlag size={16} />,
                },
                {
                  value: 'US',
                  label: 'Acme Inc.',
                  leading: <USFlag size={16} />,
                },
              ],
              getValue: (row) => row.entity,
            },
            {
              id: 'due',
              label: 'Due date',
              type: 'date',
              icon: <CalendarIcon size={16} />,
              getValue: (row) => row.due,
              presets: [
                {
                  id: 'october',
                  label: 'October 2026',
                  start: '2026-10-01',
                  end: '2026-10-31',
                },
                {
                  id: 'quarter',
                  label: 'Q4 2026',
                  start: '2026-10-01',
                  end: '2026-12-31',
                },
              ],
            },
            {
              id: 'owner',
              label: 'Assignee',
              type: 'single',
              initiallyVisible: false,
              options: [
                'Lucas Moreau',
                'Priya Shah',
                'Aisha Khan',
                'Jun Tan',
              ].map((label) => ({ value: label, label })),
              getValue: (row) => row.owner,
            },
            {
              id: 'amount',
              label: 'Amount',
              type: 'number',
              initiallyVisible: false,
              getValue: (row) => row.amount,
            },
          ],
        }}
        toolbarConfig={{
          onExport: () => {
            setNotice('Export requested for the current filter state');
          },
        }}
      />
      <p
        role="status"
        className="pf:m-0 pf:text-copy-12 pf:text-soft-pearl-800"
      >
        {notice ||
          'Fields combine with AND. Selected values within a field combine with OR. Date presets use a fixed demonstration period.'}
      </p>
    </div>
  );
};
```


This implements **Option A** from Figma: one compound chip per active filter, with **field · operator · value · remove**. Change Status from “is either of” to “is not”, search Entity options, or open Due date to choose a preset or calendar range. Use More filters to add Assignee or Amount; the field editor opens immediately. Resize the container to reveal the +N filter overflow menu.

The toolbar keeps quiet field buttons. Applied chips wrap below it. Multiple choices stay checked while searching, selected options appear first, and country flags lead entity names. Each menu clears only its own field; Clear filters resets the whole filter map. Date changes apply once a complete range exists. Numeric conditions apply after validation.

Fields combine with **AND**; multiple selected choices within one field combine with **OR**. “is not” negates membership. The app controls the value map and can control operators independently. For remote tables, serialize both into your API request and query-selection key; the table never re-filters a server page. Missing dates/numbers do not match a numeric/date condition. Arbitrarily nested AND/OR expressions are not part of this field-filter API.

## Pagination [#pagination]


```tsx
import { useState } from 'react';

import { Table } from '@commenda/pathfinder/table';

const invoices = Array.from({ length: 12 }, (_, index) => ({
  id: `INV-${String(index + 1).padStart(3, '0')}`,
  customer: ['Meridian GmbH', 'Meridian US Inc.', 'Meridian UK Ltd.'][
    index % 3
  ],
  amount: (index + 1) * 250,
}));

export const TableDataDefault = () => {
  const [page, setPage] = useState({ page: 1, pageSize: 5 });

  return (
    <Table
      label="Paginated invoices"
      rows={invoices}
      rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.id }}
      columns={[
        { id: 'invoice', header: 'Invoice', value: (row) => row.id },
        {
          id: 'customer',
          header: 'Customer',
          width: 240,
          value: (row) => row.customer,
        },
        {
          id: 'amount',
          header: 'Amount (USD)',
          align: 'end',
          value: (row) => row.amount,
        },
      ]}
      paginationConfig={{
        value: page,
        onValueChange: setPage,
        pageSizes: [5, 10],
      }}
    />
  );
};
```


Move through twelve records in pages of five, then change Rows per page. The example controls `{ page, pageSize }` and offers 5/10 for demonstration. Defaults are page **1**, 25 rows, and 25/50/100 choices. Filtering and sorting apply to the full local collection before it is sliced.

## Column visibility [#column-visibility]


```tsx
import { useState } from 'react';

import { Table } from '@commenda/pathfinder/table';

const invoices = Array.from({ length: 12 }, (_, index) => ({
  id: `INV-${String(index + 1).padStart(3, '0')}`,
  customer: ['Meridian GmbH', 'Meridian US Inc.', 'Meridian UK Ltd.'][
    index % 3
  ],
  amount: (index + 1) * 250,
}));

export const TableDataVisibility = () => {
  const [hidden, setHidden] = useState<readonly string[]>(['amount']);

  return (
    <div className="pf:w-full pf:space-y-md">
      <Table
        label="Column preferences"
        rows={invoices.slice(0, 3)}
        rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.id }}
        columns={[
          {
            id: 'invoice',
            header: 'Invoice',
            hideable: false,
            value: (row) => row.id,
          },
          {
            id: 'customer',
            header: 'Customer',
            width: 240,
            value: (row) => row.customer,
          },
          {
            id: 'amount',
            header: 'Amount (USD)',
            align: 'end',
            value: (row) => row.amount,
          },
        ]}
        viewConfig={{
          hiddenColumns: { value: hidden, onValueChange: setHidden },
        }}
      />
      <p role="status" className="pf:text-label-12 pf:text-soft-pearl-800">
        {hidden.length
          ? `Hidden columns: ${hidden.join(', ')}`
          : 'All columns visible'}
      </p>
    </div>
  );
};
```


Open Columns and enable Amount. Invoice cannot be hidden because it identifies the record. The controlled `viewConfig.hiddenColumns` array can be stored in application preferences; column IDs should survive label changes and translations. Density and visibility can be controlled independently.

## Column pinning [#column-pinning]


```tsx
import { useState } from 'react';

import { Button } from '@commenda/pathfinder/button';
import { Table, TableCell, TableNumberCell } from '@commenda/pathfinder/table';

const records = [
  {
    id: 'INV-001',
    customer: 'Meridian GmbH',
    entity: 'Germany',
    owner: 'Lucas Moreau',
    status: 'Paid',
    amount: 4800,
  },
  {
    id: 'INV-002',
    customer: 'Meridian US Inc.',
    entity: 'United States',
    owner: 'Priya Shah',
    status: 'Open',
    amount: 2150,
  },
  {
    id: 'INV-003',
    customer: 'Meridian UK Ltd.',
    entity: 'United Kingdom',
    owner: 'Aisha Khan',
    status: 'Open',
    amount: 3200,
  },
];

export const TableLayoutDefault = () => {
  const [pins, setPins] = useState<
    Readonly<Partial<Record<string, 'start' | 'end' | null>>>
  >({ id: 'start', amount: 'end' });

  return (
    <div className="pf:w-full pf:min-w-0 pf:space-y-lg">
      <Table
        label="Pinned invoices"
        rows={records}
        rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.id }}
        selectionConfig={{}}
        viewConfig={{ columnPinning: { value: pins, onValueChange: setPins } }}
        columns={[
          {
            id: 'id',
            header: 'Invoice',
            width: 140,
            hideable: false,
            value: (row) => row.id,
          },
          {
            id: 'customer',
            header: 'Customer',
            width: 280,
            cell: (row) => <TableCell value={row.customer} />,
          },
          {
            id: 'entity',
            header: 'Entity',
            width: 180,
            value: (row) => row.entity,
          },
          {
            id: 'owner',
            header: 'Owner',
            width: 180,
            value: (row) => row.owner,
          },
          {
            id: 'status',
            header: 'Status',
            width: 120,
            value: (row) => row.status,
          },
          {
            id: 'amount',
            header: 'Amount',
            width: 160,
            align: 'end',
            cell: (row) => <TableNumberCell value={row.amount} unit="USD" />,
          },
        ]}
      />
      <p
        role="status"
        className="pf:m-0 pf:text-copy-12 pf:text-soft-pearl-800"
      >
        Scroll horizontally. Invoice starts pinned; Amount ends pinned. Change
        pins from Columns.
      </p>
      <Button
        size="sm"
        variant="secondary"
        onClick={() => {
          setPins({
            id: null,
            amount: null,
            customer: null,
            entity: null,
            owner: null,
            status: null,
          });
        }}
      >
        Unpin all columns
      </Button>
    </div>
  );
};
```


Scroll horizontally: Invoice stays at the logical start and Amount at the end. Open Columns, then a column's pin control to move it or unpin it. Selection and action cells remain at their edges alongside pinned data. Hidden columns do not leave gaps. Pinning uses logical edges in RTL and releases in a narrow container when the pinned regions would obscure readable data.

Use `column.pin` for a fixed default or `viewConfig.columnPinning` for interactive preferences. Its value is a map of column IDs to `'start'`, `'end'`, or `null` (explicitly unpinned). `pinnable: false` locks a column's configured position.

## Drag column widths [#drag-column-widths]


```tsx
import { useState } from 'react';

import { Button } from '@commenda/pathfinder/button';
import { Table, TableCell, TableNumberCell } from '@commenda/pathfinder/table';

const records = [
  {
    id: 'INV-001',
    customer: 'Meridian GmbH',
    entity: 'Germany',
    owner: 'Lucas Moreau',
    status: 'Paid',
    amount: 4800,
  },
  {
    id: 'INV-002',
    customer: 'Meridian US Inc.',
    entity: 'United States',
    owner: 'Priya Shah',
    status: 'Open',
    amount: 2150,
  },
  {
    id: 'INV-003',
    customer: 'Meridian UK Ltd.',
    entity: 'United Kingdom',
    owner: 'Aisha Khan',
    status: 'Open',
    amount: 3200,
  },
];

export const TableLayoutResizing = () => {
  const [widths, setWidths] = useState<
    Readonly<Partial<Record<string, number>>>
  >({});

  return (
    <div className="pf:w-full pf:min-w-0 pf:space-y-lg">
      <Table
        label="Resizable invoices"
        rows={records}
        rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.id }}
        viewConfig={{
          columnWidths: { value: widths, onValueChange: setWidths },
          columnPinning: { defaultValue: { id: 'start' } },
        }}
        columns={[
          {
            id: 'id',
            header: 'Invoice',
            width: 140,
            minWidth: 100,
            maxWidth: 240,
            value: (row) => row.id,
          },
          {
            id: 'customer',
            header: 'Customer',
            width: 280,
            minWidth: 160,
            maxWidth: 440,
            cell: (row) => <TableCell value={row.customer} />,
          },
          {
            id: 'owner',
            header: 'Owner',
            width: 200,
            minWidth: 160,
            maxWidth: 320,
            value: (row) => row.owner,
          },
          {
            id: 'amount',
            header: 'Amount',
            width: 160,
            minWidth: 140,
            maxWidth: 240,
            align: 'end',
            cell: (row) => <TableNumberCell value={row.amount} unit="USD" />,
          },
        ]}
      />
      <p
        role="status"
        className="pf:m-0 pf:text-copy-12 pf:text-soft-pearl-800"
      >
        Customer width: {widths.customer ?? 280}px. Drag a header edge, or focus
        it and use Left/Right (10px), Shift (50px), Home/End.
      </p>
      <Button
        size="sm"
        variant="secondary"
        onClick={() => {
          setWidths({});
        }}
      >
        Reset column widths
      </Button>
    </div>
  );
};
```


Drag a header's trailing edge to increase or decrease its width. Focus the handle to use Left/Right in 10px steps, Shift for 50px, or Home/End for the bounds. Escape during a drag restores its starting width. Resize does not trigger sorting; pinned offsets follow the updated widths.

Enable handles with `viewConfig.columnWidths`. Set each column's `width`, `minWidth`, `maxWidth`, and optionally `resizable: false`. The default bounds are 80–800px; choose bounds appropriate to the content, especially money and dates. Controlled width and pin maps can be saved in application preferences. Explicit layout widths preserve accurate drag distances and pin offsets; a table can leave unused space when its configured total is narrower than its container.

## Row selection [#row-selection]


```tsx
import { useState } from 'react';

import { Table, type TableSelection } from '@commenda/pathfinder/table';

const invoices = [
  { id: 'INV-001', customer: 'Meridian GmbH', status: 'Paid', amount: 2400 },
  {
    id: 'INV-002',
    customer: 'Meridian US Inc.',
    status: 'Pending',
    amount: 1800,
  },
  {
    id: 'INV-003',
    customer: 'Meridian UK Ltd.',
    status: 'Overdue',
    amount: 950,
  },
  {
    id: 'INV-004',
    customer: 'Meridian Singapore',
    status: 'Pending',
    amount: 3200,
  },
];

export const TableInteractionsSelection = () => {
  const [selection, setSelection] = useState<TableSelection>({
    kind: 'ids',
    ids: [],
  });
  const [notice, setNotice] = useState(
    'Select unpaid invoices to request a reminder.'
  );

  return (
    <div className="pf:w-full pf:space-y-md">
      <Table
        label="Invoice selection"
        rows={invoices}
        rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.id }}
        columns={[
          { id: 'invoice', header: 'Invoice', value: (row) => row.id },
          {
            id: 'customer',
            header: 'Customer',
            width: 240,
            value: (row) => row.customer,
          },
          { id: 'status', header: 'Status', value: (row) => row.status },
        ]}
        paginationConfig={{
          defaultValue: { page: 1, pageSize: 2 },
          pageSizes: [2, 4],
        }}
        selectionConfig={{
          value: selection,
          onValueChange: setSelection,
          canSelect: (row) => row.status !== 'Paid',
          allMatching: { queryKey: 'demo-unpaid-invoices', total: 3 },
          actions: [
            {
              id: 'remind',
              label: 'Request reminder',
              onAction: () => {
                const count =
                  selection.kind === 'all'
                    ? 3 - selection.excludedIds.length
                    : selection.ids.length;
                setNotice(
                  `Reminder requested for ${String(count)} invoices. No messages were sent by this demo.`
                );
              },
            },
          ],
        }}
      />
      <p role="status" className="pf:text-label-12 pf:text-soft-pearl-800">
        {notice}
      </p>
    </div>
  );
};
```


Select an unpaid invoice, move to the next page, or choose **Select all 3** in the bulk bar. Paid invoices are ineligible. Selection persists by stable ID. Request reminder updates the status message only; this demo does not send messages.

`TableSelection` is `{ kind: 'ids', ids }` or `{ kind: 'all', queryKey, excludedIds }`. The header checkbox selects the current eligible page. `allMatching` explicitly enables the entire eligible query, including unloaded pages. Pass a new query key when filter membership or authorization scope changes. The server must interpret that query/exclusion model and recheck permissions.

## Row actions and copying [#row-actions-and-copying]


```tsx
import { useState } from 'react';

import { Table, TableCell } from '@commenda/pathfinder/table';

const invoices = [
  { id: 'INV-001', customer: 'Meridian GmbH', status: 'Paid', amount: 2400 },
  {
    id: 'INV-002',
    customer: 'Meridian US Inc.',
    status: 'Pending',
    amount: 1800,
  },
  {
    id: 'INV-003',
    customer: 'Meridian UK Ltd.',
    status: 'Overdue',
    amount: 950,
  },
  {
    id: 'INV-004',
    customer: 'Meridian Singapore',
    status: 'Pending',
    amount: 3200,
  },
];

export const TableInteractionsActions = () => {
  const [notice, setNotice] = useState('Open a row or use its menu.');

  return (
    <div className="pf:w-full pf:space-y-md">
      <Table
        label="Invoice actions"
        rows={invoices}
        rowConfig={{
          getId: (row) => row.id,
          getLabel: (row) => row.id,
          onActivate: (row) => {
            setNotice(`Opening ${row.id}`);
          },
          menu: (row) => ({
            groups: [
              {
                id: 'record',
                items: [
                  {
                    id: 'view',
                    type: 'action',
                    label: 'View invoice',
                    onClick: () => {
                      setNotice(`Opening ${row.id}`);
                    },
                  },
                  {
                    id: 'download',
                    type: 'action',
                    label: 'Download receipt',
                    disabled: row.status !== 'Paid',
                    disabledReason: 'A receipt is available after payment.',
                    onClick: () => {
                      setNotice(`Receipt download requested for ${row.id}`);
                    },
                  },
                ],
              },
            ],
          }),
        }}
        columns={[
          {
            id: 'invoice',
            header: 'Invoice',
            value: (row) => row.id,
            cell: (row) => (
              <TableCell
                type="copy"
                value={row.id}
                copyLabel={`Copy invoice number ${row.id}`}
                onCopy={() => {
                  void navigator.clipboard.writeText(row.id).then(
                    () => {
                      setNotice(`${row.id} copied`);
                    },
                    () => {
                      setNotice(
                        'Clipboard unavailable. Select the invoice number to copy it.'
                      );
                    }
                  );
                }}
              />
            ),
          },
          {
            id: 'customer',
            header: 'Customer',
            width: 240,
            value: (row) => row.customer,
          },
          { id: 'status', header: 'Status', value: (row) => row.status },
        ]}
      />
      <p role="status" className="pf:text-label-12 pf:text-soft-pearl-800">
        {notice}
      </p>
    </div>
  );
};
```


Click a record to open it, use its overflow menu, or hover/focus the invoice number to copy it. Those controls do not also activate the row. Download receipt is unavailable before payment. The example reports requested actions locally; real navigation/downloads belong to the application.

Use specific action names and at most a primary icon, secondary icon and overflow per row. Keep whole-record editing in a Sheet; reserve inline editing for frequently changed fields.

## Custom cells [#custom-cells]


```tsx
import { useState } from 'react';

import { DEFlag } from '@commenda/pathfinder/flags/DEFlag';
import { USFlag } from '@commenda/pathfinder/flags/USFlag';
import { EyeIcon } from '@commenda/pathfinder/icons/EyeIcon';
import {
  Table,
  TableCell,
  TableRowActions,
  type TableSelection,
} from '@commenda/pathfinder/table';

const filingRows = [
  {
    id: '1',
    filing: 'Delaware annual report',
    description: 'Annual compliance · 2026',
    entity: 'Meridian US Inc.',
    country: 'US',
    assignee: 'Priya Shah',
    status: 'In progress',
    due: '2026-10-15',
  },
  {
    id: '2',
    filing: 'VAT return Q3',
    description: 'Quarterly tax return',
    entity: 'Meridian GmbH',
    country: 'DE',
    assignee: 'Lucas Moreau',
    status: 'To do',
    due: '2026-10-20',
  },
  {
    id: '3',
    filing: 'Corporate income tax',
    description: 'Financial year 2025',
    entity: 'Meridian US Inc.',
    country: 'US',
    assignee: 'Aisha Khan',
    status: 'Done',
    due: '2026-10-25',
  },
  {
    id: '4',
    filing: 'Financial statements',
    description: 'Annual financial reporting',
    entity: 'Meridian GmbH',
    country: 'DE',
    assignee: 'Jun Tan',
    status: 'To do',
    due: '2026-10-30',
  },
];

export const TableOverview = () => {
  const [filters, setFilters] = useState<Record<string, readonly string[]>>({});
  const [selection, setSelection] = useState<TableSelection>({
    kind: 'ids',
    ids: [],
  });
  const [notice, setNotice] = useState('');

  return (
    <div className="pf:w-full pf:min-w-0">
      <Table
        label="Filings"
        rows={filingRows}
        rowConfig={{
          getId: (row) => row.id,
          getLabel: (row) => row.filing,
          onActivate: (row) => {
            setNotice(`Opened ${row.filing}`);
          },
        }}
        columns={[
          {
            id: 'filing',
            header: 'Filing',
            width: 260,
            value: (row) => row.filing,
            sortable: true,
            hideable: false,
            cell: (row) => (
              <TableCell value={row.filing} description={row.description} />
            ),
          },
          {
            id: 'entity',
            header: 'Entity',
            width: 200,
            value: (row) => row.entity,
            cell: (row) => (
              <TableCell
                type="entity"
                label={row.entity}
                icon={row.country === 'US' ? <USFlag /> : <DEFlag />}
              />
            ),
          },
          {
            id: 'assignee',
            header: 'Assignee',
            width: 180,
            value: (row) => row.assignee,
            cell: (row) => (
              <TableCell
                type="person"
                identity={{
                  kind: 'person',
                  userId: row.assignee,
                  name: row.assignee,
                }}
              />
            ),
          },
          {
            id: 'status',
            header: 'Status',
            align: 'center',
            width: 120,
            value: (row) => row.status,
            cell: (row) => (
              <TableCell
                type="badge"
                items={[
                  {
                    label: row.status,
                    tone:
                      row.status === 'Done'
                        ? 'success'
                        : row.status === 'To do'
                          ? 'neutral'
                          : 'info',
                  },
                ]}
              />
            ),
          },
          {
            id: 'due',
            header: 'Due date',
            width: 120,
            value: (row) => row.due,
            sortable: true,
          },
          {
            id: 'actions',
            header: 'Actions',
            width: 56,
            hideable: false,
            cell: (row) => (
              <TableRowActions
                primary={{
                  label: `View ${row.filing}`,
                  icon: <EyeIcon />,
                  onAction: () => {
                    setNotice(`Opened ${row.filing}`);
                  },
                }}
              />
            ),
          },
        ]}
        searchConfig={{
          getText: (row) => `${row.filing} ${row.entity} ${row.assignee}`,
        }}
        filterConfig={{
          filters: [
            {
              id: 'status',
              label: 'Status',
              options: ['To do', 'In progress', 'Done'].map((value) => ({
                value,
                label: value,
              })),
              matches: (row, values) => values.includes(row.status),
            },
          ],
          value: filters,
          onValueChange: setFilters,
        }}
        selectionConfig={{
          value: selection,
          onValueChange: setSelection,
          actions: [
            {
              id: 'assign',
              label: 'Assign',
              onAction: () => {
                setNotice('Assignment requested for selected records');
              },
            },
          ],
        }}
        toolbarConfig={{
          onExport: () => {
            setNotice('Export requested');
          },
        }}
      />
      {notice && <p role="status">{notice}</p>}
    </div>
  );
};
```


This filing table combines descriptions, country flags, assignees, status badges and row actions in a real record layout. `cell(row)` composes existing Pathfinder components without changing the underlying record shape. Entity names use flags; people use Avatar. System status uses Badge, while selected removable values use Tag.

Text aligns at the leading edge, numbers at the trailing edge, and badges align with their header. Missing values use an en dash, not zero. Use `TableNumberCell` for consistent decimals and ISO currency labels. Long names can grow a row rather than lose essential content.

## Grouped records [#grouped-records]


```tsx
import { useState } from 'react';

import { DEFlag } from '@commenda/pathfinder/flags/DEFlag';
import { USFlag } from '@commenda/pathfinder/flags/USFlag';
import { Table, TableCell, TableNumberCell } from '@commenda/pathfinder/table';

export const TableWorkflowsDefault = () => {
  const rows = [
    {
      id: '1',
      filing: 'VAT return Q3',
      description: 'Monthly · Federal Central Tax Office',
      entity: 'DE',
      assignee: 'Lucas Moreau',
      status: 'Filed',
      fee: 0,
    },
    {
      id: '2',
      filing: 'Trade tax return',
      description: 'Annual · Munich',
      entity: 'DE',
      assignee: 'Priya Shah',
      status: 'In review',
      fee: 180,
    },
    {
      id: '3',
      filing: 'Annual accounts',
      description: 'Annual · Bundesanzeiger',
      entity: 'DE',
      assignee: 'Aisha Khan',
      status: 'Not started',
      fee: 95,
    },
    {
      id: '4',
      filing: 'Delaware annual report',
      description: 'Annual · Delaware',
      entity: 'US',
      assignee: 'Jun Tan',
      status: 'Overdue',
      fee: 450,
    },
    {
      id: '5',
      filing: 'BOI report update',
      description: 'One-time · FinCEN',
      entity: 'US',
      assignee: 'Mira de Vries',
      status: 'Due soon',
      fee: 0,
    },
  ];
  const [notice, setNotice] = useState('');

  return (
    <div className="pf:w-full pf:min-w-0">
      <Table
        label="Grouped filings"
        rows={rows}
        rowConfig={{
          getId: (row) => row.id,
          getLabel: (row) => row.filing,
          menu: (row) => ({
            groups: [
              {
                id: 'filing',
                items: [
                  {
                    id: 'review',
                    type: 'action',
                    label: 'Review filing',
                    onClick: () => {
                      setNotice(`Reviewing ${row.filing}`);
                    },
                  },
                ],
              },
            ],
          }),
        }}
        groupingConfig={{
          getId: (row) => row.entity,
          getLabel: (id) => (id === 'DE' ? 'Acme GmbH' : 'Acme Inc.'),
          leading: (id) =>
            id === 'DE' ? <DEFlag size={16} /> : <USFlag size={16} />,
          summary: (_id, pageRows) =>
            `${String(pageRows.filter((row) => row.status === 'Filed').length)} of ${String(pageRows.length)} filed`,
        }}
        selectionConfig={{}}
        columns={[
          {
            id: 'filing',
            header: 'Filing',
            width: 280,
            cell: (row) => (
              <TableCell value={row.filing} description={row.description} />
            ),
          },
          {
            id: 'assignee',
            header: 'Assignee',
            width: 180,
            cell: (row) => (
              <TableCell
                type="person"
                identity={{
                  kind: 'person',
                  name: row.assignee,
                  userId: row.assignee,
                }}
              />
            ),
          },
          {
            id: 'status',
            header: 'Status',
            width: 128,
            cell: (row) => (
              <TableCell
                type="badge"
                items={[
                  {
                    label: row.status,
                    tone:
                      row.status === 'Filed'
                        ? 'success'
                        : row.status === 'Overdue'
                          ? 'error'
                          : 'neutral',
                  },
                ]}
              />
            ),
          },
          {
            id: 'fee',
            header: 'Fee',
            width: 120,
            align: 'end',
            cell: (row) => (
              <TableNumberCell
                value={row.fee}
                unit={row.entity === 'DE' ? 'EUR' : 'USD'}
              />
            ),
          },
        ]}
      />
      <p role="status" className="pf:text-copy-12 pf:text-soft-pearl-800">
        {notice ||
          'Collapse a group, select records, then reopen it. Selection persists; counts describe the current page.'}
      </p>
    </div>
  );
};
```


Group filings by entity with a flag, count and read-only summary. Activate a group header to collapse it. Selection survives collapse, and the header checkbox still selects eligible records on the current page. Search, sort and pagination operate before grouping; page counts describe records, never group headers. A large entity may span pages. The summary callback receives the current page's group records; pass app-owned aggregates if the summary must describe the entire query.

Use `groupingConfig.getId`, `getLabel`, optional `leading`, `summary`, and controlled/uncontrolled `collapsedGroups`. For recursive financial rows, use [DataGrid hierarchy](/docs/components/data-grid#expandable-rows-and-totals).

## Toolbar actions [#toolbar-actions]


```tsx
import { useState } from 'react';

import { FilePdfIcon } from '@commenda/pathfinder/file-types/FilePdfIcon';
import { PlusIcon } from '@commenda/pathfinder/icons/PlusIcon';
import { Table, TableCell } from '@commenda/pathfinder/table';

export const TableWorkflowsActions = () => {
  const [notice, setNotice] = useState('');

  return (
    <div className="pf:w-full pf:min-w-0">
      <Table
        label="Document actions"
        rows={[
          { id: '1', name: 'Articles of association.pdf' },
          { id: '2', name: 'Register of directors.pdf' },
        ]}
        columns={[
          {
            id: 'name',
            header: 'Document',
            width: 400,
            cell: (row) => (
              <TableCell
                type="file"
                label={row.name}
                icon={<FilePdfIcon size={16} />}
              />
            ),
          },
        ]}
        rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.name }}
        searchConfig={{ getText: (row) => row.name }}
        toolbarConfig={{
          onExport: () => {
            setNotice('Export requested');
          },
          secondaryAction: {
            id: 'folder',
            label: 'New folder',
            onAction: () => {
              setNotice('New folder requested');
            },
          },
          primaryAction: {
            id: 'upload',
            label: 'Upload',
            icon: <PlusIcon />,
            onAction: () => {
              setNotice('Upload files requested');
            },
            menu: {
              groups: [
                {
                  id: 'upload',
                  items: [
                    {
                      id: 'folder',
                      type: 'action',
                      label: 'Upload folder',
                      onClick: () => {
                        setNotice('Upload folder requested');
                      },
                    },
                  ],
                },
              ],
            },
          },
          moreMenu: {
            groups: [
              {
                id: 'extra',
                items: [
                  {
                    id: 'refresh',
                    type: 'action',
                    label: 'Refresh documents',
                    onClick: () => {
                      setNotice('Refresh requested');
                    },
                  },
                ],
              },
            ],
          },
        }}
      />
      <p role="status" className="pf:text-copy-12 pf:text-soft-pearl-800">
        {notice ||
          'Demonstration actions update this message. Your application supplies the upload, export and folder workflows.'}
      </p>
    </div>
  );
};
```


The right side contains tools, overflow, a divider, then at most one secondary and one primary action. Export becomes an icon when actions are present. A primary action with `menu` renders a SplitButton: the main button performs the default action, while the arrow exposes alternatives. Selection actions belong in the bulk bar.

Use named `primaryAction` and `secondaryAction`; put extra actions in `moreMenu`. These examples report callbacks locally. Your application supplies upload, export and navigation workflows.

## Documents [#documents]


```tsx
import { useState } from 'react';

import { FilePdfIcon } from '@commenda/pathfinder/file-types/FilePdfIcon';
import { DEFlag } from '@commenda/pathfinder/flags/DEFlag';
import { USFlag } from '@commenda/pathfinder/flags/USFlag';
import { Table, TableCell } from '@commenda/pathfinder/table';

export const TableWorkflowsDocuments = () => {
  const [notice, setNotice] = useState('');
  const rows = [
    {
      id: 'HRB 284731',
      name: 'Delaware annual report.pdf',
      country: 'DE',
      entity: 'Acme GmbH',
      person: 'Priya Shah',
      date: '2026-10-02',
    },
    {
      id: 'DE-7745120',
      name: 'Articles of association.pdf',
      country: 'US',
      entity: 'Acme Inc.',
      person: 'Lucas Moreau',
      date: '2026-09-28',
    },
  ];

  return (
    <div className="pf:w-full pf:min-w-0">
      <Table
        label="Documents"
        rows={rows}
        rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.name }}
        sortingConfig={{
          defaultValue: { columnId: 'uploaded', direction: 'desc' },
        }}
        columns={[
          {
            id: 'name',
            header: 'Document',
            width: 300,
            cell: (row) => (
              <TableCell
                type="file"
                label={row.name}
                icon={<FilePdfIcon size={16} />}
              />
            ),
          },
          {
            id: 'entity',
            header: 'Entity',
            width: 180,
            cell: (row) => (
              <TableCell
                type="entity"
                label={row.entity}
                icon={
                  row.country === 'DE' ? (
                    <DEFlag size={16} />
                  ) : (
                    <USFlag size={16} />
                  )
                }
              />
            ),
          },
          {
            id: 'reference',
            header: 'Reference',
            width: 170,
            cell: (row) => (
              <TableCell
                type="copy"
                value={row.id}
                onCopy={() => {
                  void navigator.clipboard.writeText(row.id).then(
                    () => {
                      setNotice(`Copied ${row.id}`);
                    },
                    () => {
                      setNotice('Clipboard permission is unavailable');
                    }
                  );
                }}
              />
            ),
          },
          {
            id: 'person',
            header: 'Uploaded by',
            width: 170,
            cell: (row) => (
              <TableCell
                type="person"
                identity={{
                  kind: 'person',
                  name: row.person,
                  userId: row.person,
                }}
              />
            ),
          },
          {
            id: 'uploaded',
            header: 'Uploaded',
            width: 140,
            sortable: true,
            value: (row) => row.date,
          },
        ]}
      />
      <p role="status" className="pf:text-copy-12 pf:text-soft-pearl-800">
        {notice || 'Copy a reference or change the upload-date sort.'}
      </p>
    </div>
  );
};
```


File icons identify documents, country flags identify entities, and avatars identify uploaders. References expose a copy action on hover, focus or touch. The demo writes the selected reference to the clipboard and reports failures.

## Team members [#team-members]


```tsx
import { useState } from 'react';

import { Table, TableCell } from '@commenda/pathfinder/table';

export const TablePeopleDefault = () => {
  const [notice, setNotice] = useState('');
  const people = [
    {
      id: 'priya',
      name: 'Priya Shah',
      email: 'priya@acme.com',
      role: 'Admin',
      status: 'Active',
      twoFactor: true,
      active: 'Today',
    },
    {
      id: 'lucas',
      name: 'Lucas Moreau',
      email: 'lucas@acme.com',
      role: 'Member',
      status: 'Active',
      twoFactor: true,
      active: 'Yesterday',
    },
    {
      id: 'aisha',
      name: 'Aisha Khan',
      email: 'aisha@acme.com',
      role: 'Read only',
      status: 'Invited',
      twoFactor: false,
      active: null,
    },
    {
      id: 'jun',
      name: 'Jun Tan',
      email: 'jun@acme.com',
      role: 'Member',
      status: 'Deactivated',
      twoFactor: false,
      active: '12 Aug 2026',
    },
  ];

  return (
    <div className="pf:w-full pf:min-w-0">
      <Table
        label="Team members"
        rows={people}
        searchConfig={{ getText: (row) => `${row.name} ${row.email}` }}
        rowConfig={{
          getId: (row) => row.id,
          getLabel: (row) => row.name,
          menu: (row) => ({
            groups: [
              {
                id: 'member',
                items: [
                  {
                    id: 'details',
                    type: 'action',
                    label: 'View member',
                    onClick: () => {
                      setNotice(`Viewing ${row.name}`);
                    },
                  },
                ],
              },
            ],
          }),
        }}
        columns={[
          {
            id: 'name',
            header: 'Member',
            width: 280,
            cell: (row) => (
              <TableCell
                type="person"
                identity={{ kind: 'person', userId: row.id, name: row.name }}
                description={row.email}
              />
            ),
          },
          {
            id: 'role',
            header: 'Role',
            width: 150,
            cell: (row) => (
              <TableCell type="tag" items={[{ label: row.role }]} />
            ),
          },
          {
            id: 'status',
            header: 'Status',
            width: 120,
            cell: (row) => (
              <TableCell
                type="badge"
                items={[
                  {
                    label: row.status,
                    tone:
                      row.status === 'Active'
                        ? 'success'
                        : row.status === 'Invited'
                          ? 'info'
                          : 'neutral',
                  },
                ]}
              />
            ),
          },
          {
            id: 'twoFactor',
            header: '2FA',
            width: 80,
            align: 'center',
            cell: (row) => (
              <TableCell
                type="boolean"
                value={row.twoFactor}
                label={
                  row.twoFactor ? 'Two-factor enabled' : 'Two-factor disabled'
                }
              />
            ),
          },
          {
            id: 'active',
            header: 'Last active',
            width: 140,
            value: (row) => row.active,
          },
        ]}
      />
      <p role="status" className="pf:text-copy-12 pf:text-soft-pearl-800">
        {notice ||
          'Roles are values (Tags); account statuses are read-only Badges.'}
      </p>
    </div>
  );
};
```


People include their email, role as a Tag, account status as a Badge and an accessible yes/no symbol for two-factor authentication. Missing activity is an en dash. Row-specific actions live in the overflow menu.

## Density and containers [#density-and-containers]


```tsx
import { useState } from 'react';

import { Button } from '@commenda/pathfinder/button';
import { Table, TableCell } from '@commenda/pathfinder/table';

export const TablePatternsDefault = () => {
  const [density, setDensity] = useState<'compact' | 'relaxed'>('compact');
  const [container, setContainer] = useState<'card' | 'flat'>('card');

  return (
    <div className="pf:w-full pf:min-w-0 pf:space-y-lg">
      <Button
        size="sm"
        variant="secondary"
        onClick={() => {
          setContainer(container === 'card' ? 'flat' : 'card');
        }}
      >
        Container: {container}
      </Button>
      <Table
        label="Density and containers"
        container={container}
        viewConfig={{ density: { value: density, onValueChange: setDensity } }}
        rows={[
          { id: 'vat', label: 'VAT return Q3', description: undefined },
          {
            id: 'annual',
            label: 'Annual accounts',
            description: 'Financial year ending 31 Dec 2026',
          },
        ]}
        rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.label }}
        columns={[
          {
            id: 'filing',
            header: 'Filing',
            width: 300,
            cell: (row) => (
              <TableCell value={row.label} description={row.description} />
            ),
          },
          {
            id: 'status',
            header: 'Status',
            width: 140,
            align: 'end',
            cell: () => (
              <TableCell
                type="badge"
                items={[{ label: 'In review', tone: 'info' }]}
              />
            ),
          },
        ]}
      />
    </div>
  );
};
```


Use the view menu to compare compact and relaxed density, and the button to switch card/flat containers. The second record includes a description to show the two-line behavior.

| Measurement             | Compact | Relaxed |
| ----------------------- | ------- | ------- |
| Header                  | 36px    | 36px    |
| Single-line row minimum | 40px    | 52px    |
| Two-line text row       | 52px    | 64px    |
| Horizontal cell padding | 12px    | 12px    |

A card adds a 12px radius and hairline border. Use flat inside an existing surface. Wrapped content, text enlargement and taller custom controls can increase these heights.

## Presets and summary [#presets-and-summary]


```tsx
import { useState } from 'react';

import { Table, TableCell, TableNumberCell } from '@commenda/pathfinder/table';

export const TablePatternsPresets = () => {
  const rows = [
    { id: '1', label: 'Annual accounts', status: 'Overdue', amount: 2400 },
    { id: '2', label: 'VAT return Q3', status: 'Due soon', amount: 1800 },
    { id: '3', label: 'Trade tax return', status: 'Filed', amount: 950 },
    { id: '4', label: 'Corporate income tax', status: 'Filed', amount: 1250 },
  ];
  const [filters, setFilters] = useState<{ status?: readonly string[] }>({});
  const selected =
    filters.status?.length === 1
      ? filters.status[0]
      : filters.status?.length
        ? undefined
        : 'All';
  const visible = rows.filter(
    (row) => !filters.status?.length || filters.status.includes(row.status)
  );

  return (
    <div className="pf:w-full pf:min-w-0 pf:space-y-lg">
      <div
        role="group"
        aria-label="Filing presets"
        className="pf:grid pf:grid-cols-2 pf:sm:grid-cols-4 pf:gap-lg"
      >
        {['All', 'Overdue', 'Due soon', 'Filed'].map((label) => (
          <button
            key={label}
            type="button"
            aria-pressed={selected === label}
            onClick={() => {
              setFilters(label === 'All' ? {} : { status: [label] });
            }}
            className="pf:min-w-0 pf:rounded-xl pf:border pf:border-solid pf:border-soft-pearl-300 pf:bg-background pf:p-lg pf:text-start pf:text-inherit pf:font-inherit pf:cursor-pointer pf:aria-pressed:border-blue-700 pf:focus-visible:outline-2 pf:focus-visible:outline-blue-700"
          >
            <span className="pf:block pf:text-label-12 pf:text-soft-pearl-800">
              {label}
            </span>
            <span className="pf:block pf:mt-md pf:text-heading-20">
              {label === 'All'
                ? rows.length
                : rows.filter((row) => row.status === label).length}
            </span>
          </button>
        ))}
      </div>
      <Table
        label="Preset filings"
        rows={rows}
        rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.label }}
        columns={[
          {
            id: 'filing',
            header: 'Filing',
            width: 260,
            value: (row) => row.label,
          },
          {
            id: 'amount',
            header: 'Amount',
            width: 160,
            align: 'end',
            cell: (row) => <TableNumberCell value={row.amount} unit="USD" />,
          },
          {
            id: 'status',
            header: 'Status',
            width: 140,
            align: 'end',
            cell: (row) => (
              <TableCell
                type="badge"
                items={[
                  {
                    label: row.status,
                    tone:
                      row.status === 'Filed'
                        ? 'success'
                        : row.status === 'Overdue'
                          ? 'error'
                          : 'warning',
                  },
                ]}
              />
            ),
          },
        ]}
        searchConfig={{}}
        filterConfig={{
          value: filters,
          onValueChange: setFilters,
          filters: [
            {
              id: 'status',
              label: 'Status',
              options: ['Overdue', 'Due soon', 'Filed'].map((label) => ({
                value: label,
                label,
              })),
              matches: (row, values) => values.includes(row.status),
            },
          ],
        }}
        summary={
          <div className="pf:flex pf:flex-wrap pf:gap-3xl">
            <span>
              Matching records <strong>{visible.length}</strong>
            </span>
            <span>
              Total due{' '}
              <strong>
                {new Intl.NumberFormat('en-US', {
                  style: 'currency',
                  currency: 'USD',
                }).format(visible.reduce((sum, row) => sum + row.amount, 0))}
              </strong>
            </span>
          </div>
        }
      />
    </div>
  );
};
```


Choose Overdue, then clear its toolbar filter. Preset cards and filters share state, so the selected preset updates with the query. The summary is read-only. Cards sit above the table; summary content lives inside it.

This example's summary follows the status filter. Production summaries should use aggregates for the same reporting query, including search when applicable, rather than summing one loaded API page. Preset definitions, counts and persistence are app-owned.

## Editable cells and totals [#editable-cells-and-totals]


```tsx
import { useState } from 'react';

import {
  Table,
  TableEditCell,
  TableNumberCell,
} from '@commenda/pathfinder/table';

export const TablePatternsEditing = () => {
  const [rows, setRows] = useState([
    { id: '1', name: 'Software subscriptions', amount: '2400.00' },
    { id: '2', name: 'Office rent', amount: '1800.00' },
  ]);
  const [notice, setNotice] = useState('All changes saved');

  return (
    <div className="pf:w-full pf:min-w-0">
      <Table
        label="Editable lines"
        rows={rows}
        rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.name }}
        columns={[
          {
            id: 'name',
            header: 'Account',
            width: 300,
            hideable: false,
            cell: (row) => (
              <TableEditCell
                label={`Account ${row.id}`}
                value={row.name}
                editor={{ type: 'text' }}
                validate={(value) =>
                  value.trim() ? undefined : 'Enter an account name.'
                }
                onCommit={(name) => {
                  setRows((current) =>
                    current.map((item) =>
                      item.id === row.id ? { ...item, name } : item
                    )
                  );
                  setNotice('Saved just now');
                }}
              />
            ),
          },
          {
            id: 'amount',
            header: 'Amount',
            width: 180,
            align: 'end',
            cell: (row) => (
              <TableEditCell
                label={`${row.name} amount`}
                value={row.amount}
                editor={{ type: 'number', min: 0, unit: 'USD' }}
                display={
                  <TableNumberCell
                    value={Number(row.amount)}
                    unit="USD"
                    format={{
                      minimumFractionDigits: 2,
                      maximumFractionDigits: 2,
                    }}
                  />
                }
                validate={(value) =>
                  value.trim() ? undefined : 'Enter an amount.'
                }
                onCommit={(amount) => {
                  setRows((current) =>
                    current.map((item) =>
                      item.id === row.id ? { ...item, amount } : item
                    )
                  );
                  setNotice('Saved just now');
                }}
              />
            ),
          },
        ]}
        footerConfig={{
          total: {
            label: 'Total',
            cells: {
              amount: (
                <TableNumberCell
                  value={rows.reduce((sum, row) => sum + Number(row.amount), 0)}
                  unit="USD"
                  format={{
                    minimumFractionDigits: 2,
                    maximumFractionDigits: 2,
                  }}
                />
              ),
            },
          },
          addAction: {
            id: 'add',
            label: 'Add line',
            onAction: () => {
              setRows((current) => [
                ...current,
                {
                  id: String(current.length + 1),
                  name: 'New account',
                  amount: '0.00',
                },
              ]);
              setNotice('Line added locally');
            },
          },
        }}
      />
      <p role="status" className="pf:text-label-12 pf:text-soft-pearl-800">
        {notice}
      </p>
    </div>
  );
};
```


Hover a row, click an account or amount, and edit it. Enter saves, Escape cancels, and Add line appends a record. Totals recompute from application data and align by column ID through `footerConfig.total.cells`. The first visible column contains the total label.

The example saves in memory. An async `onCommit` must resolve only after persistence succeeds and reject on failure; the editor retains rejected drafts and offers Retry save. Text/number fields use 28px compact geometry. Select and DatePicker preserve their existing accessible dimensions and can increase row height.

## Account mapping and validation [#account-mapping-and-validation]


```tsx
import { useState } from 'react';

import { Checkbox } from '@commenda/pathfinder/checkbox';
import {
  Table,
  TableEditCell,
  TableNumberCell,
} from '@commenda/pathfinder/table';

export const TableEditingDefault = () => {
  const [rows, setRows] = useState([
    {
      id: '6100',
      name: 'Office rent',
      mapping: 'OPEX',
      allocation: '60',
      amount: 48000,
    },
    {
      id: '6200',
      name: 'Software subscriptions',
      mapping: 'OPEX',
      allocation: '100',
      amount: 21340.5,
    },
    {
      id: '5000',
      name: 'Cost of goods sold',
      mapping: 'COGS',
      allocation: '100',
      amount: 412880,
    },
    {
      id: '6400',
      name: 'Shared services fee',
      mapping: 'OPEX',
      allocation: '140',
      amount: 96000,
    },
  ]);
  const [outlined, setOutlined] = useState(false);
  const [fail, setFail] = useState(false);
  const [notice, setNotice] = useState(
    'The last row has an invalid allocation. Edit it to correct the value.'
  );

  const save = (
    id: string,
    patch: { mapping?: string; allocation?: string }
  ) => {
    if (fail)
      return Promise.reject(
        new Error('Save failed. Your draft is retained; try again.')
      );
    setRows((current) =>
      current.map((row) => (row.id === id ? { ...row, ...patch } : row))
    );
    setNotice('Saved just now');

    return Promise.resolve();
  };

  return (
    <div className="pf:w-full pf:min-w-0 pf:space-y-lg">
      <div className="pf:flex pf:flex-wrap pf:gap-xl">
        <Checkbox
          label="Keep fields outlined"
          checked={outlined}
          onCheckedChange={setOutlined}
        />
        <Checkbox
          label="Simulate save failure"
          checked={fail}
          onCheckedChange={setFail}
        />
      </div>
      <Table
        label="Account mapping"
        rows={rows}
        rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.name }}
        columns={[
          { id: 'id', header: 'Account', width: 96, value: (row) => row.id },
          { id: 'name', header: 'Name', width: 260, value: (row) => row.name },
          {
            id: 'mapping',
            header: 'Mapping',
            width: 200,
            cell: (row) => (
              <TableEditCell
                label={`${row.name} mapping`}
                value={row.mapping}
                editor={{
                  type: 'select',
                  options: ['OPEX', 'COGS', 'Revenue'].map((label) => ({
                    value: label,
                    label,
                  })),
                }}
                outlined={outlined}
                onCommit={(mapping) => save(row.id, { mapping })}
              />
            ),
          },
          {
            id: 'allocation',
            header: 'Allocation',
            width: 160,
            align: 'end',
            cell: (row) => (
              <TableEditCell
                label={`${row.name} allocation`}
                value={row.allocation}
                editor={{ type: 'number', min: 0, max: 100, suffix: '%' }}
                outlined={outlined}
                validationMessage={
                  Number(row.allocation) > 100
                    ? 'Allocation cannot exceed 100%.'
                    : undefined
                }
                validate={(value) =>
                  Number(value) < 0 || Number(value) > 100 || !value.trim()
                    ? 'Enter an allocation from 0 to 100.'
                    : undefined
                }
                onCommit={(allocation) => save(row.id, { allocation })}
              />
            ),
          },
          {
            id: 'amount',
            header: 'Amount',
            width: 160,
            align: 'end',
            cell: (row) => (
              <TableNumberCell
                value={row.amount}
                unit="EUR"
                format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }}
              />
            ),
          },
        ]}
        footerConfig={{
          total: {
            label: 'Total',
            cells: {
              amount: (
                <TableNumberCell
                  value={rows.reduce((sum, row) => sum + row.amount, 0)}
                  unit="EUR"
                />
              ),
            },
          },
          addAction: {
            id: 'add',
            label: 'Add line',
            onAction: () => {
              setRows((current) => [
                ...current,
                {
                  id: `new-${String(current.length)}`,
                  name: 'New account',
                  mapping: 'OPEX',
                  allocation: '0',
                  amount: 0,
                },
              ]);
            },
          },
        }}
      />
      <p
        role="status"
        className="pf:m-0 pf:text-copy-12 pf:text-soft-pearl-800"
      >
        {notice}
      </p>
    </div>
  );
};
```


This account-mapping example combines Select and numeric editors, an existing invalid allocation, aligned money totals and Add line. Try Keep fields outlined for form-style tables. Simulate save failure retains the draft and exposes retry; disable the simulation to complete the save. The app owns validation and financial calculations.

## Dates, switches and attachments [#dates-switches-and-attachments]


```tsx
import { useRef, useState } from 'react';

import { Button } from '@commenda/pathfinder/button';
import { FilePdfIcon } from '@commenda/pathfinder/file-types/FilePdfIcon';
import { IconButton } from '@commenda/pathfinder/icon-button';
import { Upload01Icon } from '@commenda/pathfinder/icons/Upload01Icon';
import { Switch } from '@commenda/pathfinder/switch';
import { Table, TableCell, TableEditCell } from '@commenda/pathfinder/table';

export const TableEditingControls = () => {
  const [date, setDate] = useState('2026-10-30');
  const [enabled, setEnabled] = useState(true);
  const [filename, setFilename] = useState('');
  const fileInput = useRef<HTMLInputElement>(null);

  return (
    <div className="pf:w-full pf:min-w-0 pf:space-y-lg">
      <Table
        label="Editable filing controls"
        rows={[{ id: 'vat', name: 'VAT return Q3' }]}
        rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.name }}
        columns={[
          {
            id: 'name',
            header: 'Filing',
            width: 240,
            value: (row) => row.name,
          },
          {
            id: 'due',
            header: 'Due date',
            width: 220,
            cell: () => (
              <TableEditCell
                label="VAT due date"
                value={date}
                editor={{ type: 'date' }}
                onCommit={setDate}
              />
            ),
          },
          {
            id: 'enabled',
            header: 'Reminders',
            width: 120,
            cell: () => (
              <Switch
                label="Send VAT reminders"
                hideLabel
                checked={enabled}
                onCheckedChange={setEnabled}
              />
            ),
          },
          {
            id: 'attachment',
            header: 'Attachment',
            width: 260,
            cell: () =>
              filename ? (
                <TableCell
                  type="file"
                  label={filename}
                  icon={<FilePdfIcon size={16} />}
                />
              ) : (
                <IconButton
                  label="Attach PDF"
                  size="sm"
                  onClick={() => fileInput.current?.click()}
                >
                  <Upload01Icon size={16} />
                </IconButton>
              ),
          },
        ]}
      />
      <input
        ref={fileInput}
        className="pf:sr-only"
        type="file"
        accept="application/pdf,.pdf"
        aria-label="Choose PDF attachment"
        tabIndex={-1}
        onChange={(event) => {
          setFilename(event.currentTarget.files?.[0]?.name ?? '');
        }}
      />
      {filename && (
        <Button
          size="sm"
          variant="secondary"
          onClick={() => {
            setFilename('');
            if (fileInput.current) fileInput.current.value = '';
          }}
        >
          Remove attachment
        </Button>
      )}
      <p
        role="status"
        className="pf:m-0 pf:text-copy-12 pf:text-soft-pearl-800"
      >
        {filename
          ? `Selected ${filename} locally; no file was uploaded.`
          : 'Date, reminder and attachment changes are local to this example.'}
      </p>
    </div>
  );
};
```


Compose DatePicker editing, an immediate Switch and an upload action directly in cells. The picker keeps Pathfinder's approved DateField geometry. Once a PDF is chosen, its filename replaces the upload action. File selection is local in this example; no bytes leave the browser. Real uploads need application-side file validation, progress, persistence and failure handling.

## API-controlled data [#api-controlled-data]


```tsx
import { useState } from 'react';

import { Table } from '@commenda/pathfinder/table';

const filingRows = [
  {
    id: '1',
    filing: 'Delaware annual report',
    description: 'Annual compliance · 2026',
    entity: 'Meridian US Inc.',
    country: 'US',
    assignee: 'Priya Shah',
    status: 'In progress',
    due: '2026-10-15',
  },
  {
    id: '2',
    filing: 'VAT return Q3',
    description: 'Quarterly tax return',
    entity: 'Meridian GmbH',
    country: 'DE',
    assignee: 'Lucas Moreau',
    status: 'To do',
    due: '2026-10-20',
  },
  {
    id: '3',
    filing: 'Corporate income tax',
    description: 'Financial year 2025',
    entity: 'Meridian US Inc.',
    country: 'US',
    assignee: 'Aisha Khan',
    status: 'Done',
    due: '2026-10-25',
  },
  {
    id: '4',
    filing: 'Financial statements',
    description: 'Annual financial reporting',
    entity: 'Meridian GmbH',
    country: 'DE',
    assignee: 'Jun Tan',
    status: 'To do',
    due: '2026-10-30',
  },
];

export const TableRemote = () => {
  const [page, setPage] = useState({ page: 1, pageSize: 25 });
  const [query, setQuery] = useState('');
  // Replace this synchronous fixture with an API response. The app owns request cancellation.
  const matches = filingRows.filter((row) =>
    row.filing.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <Table
      label="Remote filings"
      dataMode="remote"
      rows={matches.slice(
        (page.page - 1) * page.pageSize,
        page.page * page.pageSize
      )}
      columns={[{ id: 'name', header: 'Filing', value: (row) => row.filing }]}
      rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.filing }}
      paginationConfig={{
        value: page,
        onValueChange: setPage,
        totalRows: matches.length,
      }}
      searchConfig={{ value: query, onValueChange: setQuery }}
    />
  );
};
```


`dataMode="remote"` displays exactly the rows returned by the app. Table never sorts, filters or slices that received page again. The example uses synchronous fixture filtering to demonstrate state ownership, not a production API adapter.

Use search, filters, sort and pagination as a request key. Keep typing responsive, debounce requests rather than the displayed value, cancel obsolete requests, and prevent stale responses from replacing newer results. Remote numbered pages require `paginationConfig.totalRows`. URL persistence, exports and server authorization remain application responsibilities.

### Cursor pagination [#cursor-pagination]


```tsx
import { useState } from 'react';

import { Table } from '@commenda/pathfinder/table';

const invoices = Array.from({ length: 12 }, (_, index) => ({
  id: `INV-${String(index + 1).padStart(3, '0')}`,
  customer: ['Meridian GmbH', 'Meridian US Inc.', 'Meridian UK Ltd.'][
    index % 3
  ],
  amount: (index + 1) * 250,
}));

export const TableDataCursor = () => {
  // Simulates server pages; a real API owns opaque cursors and request cancellation.
  const [pageIndex, setPageIndex] = useState(0);
  const pageSize = 4;
  const pageRows = invoices.slice(
    pageIndex * pageSize,
    (pageIndex + 1) * pageSize
  );

  return (
    <Table
      label="Cursor-paginated invoices"
      dataMode="remote"
      rows={pageRows}
      rowConfig={{ getId: (row) => row.id, getLabel: (row) => row.id }}
      columns={[
        { id: 'invoice', header: 'Invoice', value: (row) => row.id },
        {
          id: 'customer',
          header: 'Customer',
          width: 240,
          value: (row) => row.customer,
        },
      ]}
      paginationConfig={{
        mode: 'cursor',
        pageSize,
        hasPrevious: pageIndex > 0,
        hasNext: (pageIndex + 1) * pageSize < invoices.length,
        onPrevious: () => {
          setPageIndex((current) => current - 1);
        },
        onNext: () => {
          setPageIndex((current) => current + 1);
        },
        summary: `Showing ${String(pageRows.length)} records`,
      }}
    />
  );
};
```


Use Previous and Next without an invented total. The demo simulates server pages; real applications hold opaque API cursors. Supply `hasNext`, `hasPrevious`, callbacks and an honest summary. Pair cursor configuration with `dataMode="remote"`.

## Loading, empty and error [#loading-empty-and-error]


```tsx
import { useState } from 'react';

import { Button } from '@commenda/pathfinder/button';
import { Table } from '@commenda/pathfinder/table';

export const TableStates = () => {
  const [state, setState] = useState<'loading' | 'empty' | 'error'>('loading');

  return (
    <div className="pf:w-full">
      <div className="pf:flex pf:gap-md pf:mb-lg">
        {(['loading', 'empty', 'error'] as const).map((value) => (
          <Button
            key={value}
            variant="secondary"
            size="sm"
            onClick={() => {
              setState(value);
            }}
          >
            {value === 'loading'
              ? 'Loading'
              : value === 'empty'
                ? 'Empty'
                : 'Error'}
          </Button>
        ))}
      </div>
      <Table
        label="Record states"
        rows={[]}
        columns={[{ id: 'name', header: 'Filing' }]}
        rowConfig={{ getId: () => '', getLabel: () => '' }}
        stateConfig={{
          isLoading: state === 'loading',
          error:
            state === 'error'
              ? {
                  message: 'The request failed. Your filters are preserved.',
                  onRetry: () => {
                    setState('loading');
                  },
                }
              : undefined,
          empty: {
            title: 'No filings yet',
            description: 'Add your first filing to start tracking compliance.',
          },
        }}
      />
    </div>
  );
};
```


Switch between loading skeletons, first-use emptiness and a retryable error. Search/filter emptiness instead offers Clear filters. Keep toolbar and column geometry stable while requests run. Retry should repeat the current query, not discard the user's scope.

## Accessibility and responsive behavior [#accessibility-and-responsive-behavior]

Table retains native table semantics. Checkbox selection is separate from row activation; a focused activatable row opens with Enter. Search shortcuts apply while focus is in table tools. The scroll region supports keyboard access. Narrow layouts scroll within the component and release pinned columns if they would hide readable values.

Read the [Figma rules and explicit differences](/docs/components/table#design-rules) before changing dimensions or behavior.

## API reference [#api-reference]

`groupingConfig` groups the current page: `{ getId(row), getLabel(id), leading?(id), summary?(id, pageRows), collapsedGroups? }`. Collapsed state is a list of group IDs using the same `value` / `onValueChange` or `defaultValue` convention as other preferences.

`TableFilter` is discriminated by `type`:

| Prop | Type | Requirement | Behavior |
| --- | --- | --- | --- |
| `label` | `string` | required | Visible field label. Supply an accessible name when it is omitted. |
| `rows` | `readonly TRow[]` | required |  |
| `columns` | `readonly TableColumn<TRow>[]` | required |  |
| `rowConfig` | `{ getId: (row: TRow) => string; getLabel: (row: TRow) => string; onActivate?: ((row: TRow) => void) \| undefined; menu?: ((row: TRow) => DropdownMenu) \| undefined; }` | required |  |
| `viewConfig` | `{ density?: SelectionProps<"compact" \| "relaxed">; hiddenColumns?: SelectionProps<readonly string[]>; columnWidths?: SelectionProps<Readonly<Partial<Record<string, number>>>>; columnPinning?: SelectionProps<Readonly<Partial<Record<string, "start" \| "end" \| null>>>>; }` | optional |  |
| `groupingConfig` | `{ getId: (row: TRow) => string; getLabel: (id: string) => string; leading?: (id: string) => ReactNode; summary?: ((id: string, pageRows: readonly TRow[]) => ReactNode) \| undefined; collapsedGroups?: SelectionProps<readonly string[]>; }` | optional |  |
| `container` | `"card" \| "flat"` | optional |  |
| `sortingConfig` | `SelectionProps<TableSort \| null>` | optional |  |
| `searchConfig` | `SelectionProps<string> & { getText?: ((row: TRow) => string) \| undefined; placeholder?: string; }` | optional |  |
| `filterConfig` | `{ filters: readonly TableFilter<TRow>[]; operators?: SelectionProps<Readonly<Record<string, TableFilterOperator>>>; value: Readonly<Record<string, readonly string[]>>; onValueChange: (value: Record<string, readonly string[]>) => void; }` | optional |  |
| `selectionConfig` | `SelectionProps<TableSelection> & { canSelect?: ((row: TRow) => boolean) \| undefined; allMatching?: { queryKey: string; total: number; }; actions?: readonly TableAction[]; moreMenu?: DropdownMenu; }` | optional |  |
| `toolbarConfig` | `TableToolbarConfig` | optional |  |
| `stateConfig` | `TabularStateConfig` | optional |  |
| `summary` | `ReactNode` | optional |  |
| `footerConfig` | `{ total?: { label: string; cells: Readonly<Record<string, ReactNode>>; }; addAction?: TableAction; }` | optional |  |
| `className` | `string` | optional | Additional classes for the component root. |
| `dataMode` | `"local" \| "remote"` | conditional |  |
| `paginationConfig` | `{ mode?: "pages"; totalRows?: number; pageSizes?: readonly number[]; } & SelectionProps<TablePage> \| { mode: "cursor"; hasNext: boolean; hasPrevious: boolean; onNext: () => void; onPrevious: () => void; summary?: string; pageSize: number; onPageSizeChange?: (size: number) => void; } \| ({ mode?: "pages"; totalRows?: number; pageSizes?: readonly number[]; } & SelectionProps<TablePage>) & { totalRows: number; }` | conditional |  |

### rowConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `getId` | `(row: TRow) => string` | Yes |  |
| `getLabel` | `(row: TRow) => string` | Yes |  |
| `onActivate` | `((row: TRow) => void)` | No |  |
| `menu` | `((row: TRow) => DropdownMenu)` | No |  |

### viewConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `density` | `SelectionProps<"compact" \| "relaxed">` | No |  |
| `hiddenColumns` | `SelectionProps<readonly string[]>` | No |  |
| `columnWidths` | `SelectionProps<Readonly<Partial<Record<string, number>>>>` | No |  |
| `columnPinning` | `SelectionProps<Readonly<Partial<Record<string, "start" \| "end" \| null>>>>` | No |  |

### viewConfig.density fields

Choose one shape; excluded fields cannot be combined.

#### Option 1

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `value` | `"compact" \| "relaxed"` | Yes |  |
| `onValueChange` | `(value: "compact" \| "relaxed") => void` | Yes |  |

Do not supply: `defaultValue`.

#### Option 2

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `defaultValue` | `"compact" \| "relaxed"` | No |  |
| `onValueChange` | `((value: "compact" \| "relaxed") => void)` | No |  |

Do not supply: `value`.

### viewConfig.hiddenColumns fields

Choose one shape; excluded fields cannot be combined.

#### Option 1

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `value` | `readonly string[]` | Yes |  |
| `onValueChange` | `(value: readonly string[]) => void` | Yes |  |

Do not supply: `defaultValue`.

#### Option 2

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `defaultValue` | `readonly string[]` | No |  |
| `onValueChange` | `((value: readonly string[]) => void)` | No |  |

Do not supply: `value`.

### viewConfig.columnWidths fields

Choose one shape; excluded fields cannot be combined.

#### Option 1

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `value` | `Readonly<Partial<Record<string, number>>>` | Yes |  |
| `onValueChange` | `(value: Readonly<Partial<Record<string, number>>>) => void` | Yes |  |

Do not supply: `defaultValue`.

#### Option 2

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `defaultValue` | `Readonly<Partial<Record<string, number>>>` | No |  |
| `onValueChange` | `((value: Readonly<Partial<Record<string, number>>>) => void)` | No |  |

Do not supply: `value`.

### viewConfig.columnPinning fields

Choose one shape; excluded fields cannot be combined.

#### Option 1

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `value` | `Readonly<Partial<Record<string, "start" \| "end" \| null>>>` | Yes |  |
| `onValueChange` | `(value: Readonly<Partial<Record<string, "start" \| "end" \| null>>>) => void` | Yes |  |

Do not supply: `defaultValue`.

#### Option 2

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `defaultValue` | `Readonly<Partial<Record<string, "start" \| "end" \| null>>>` | No |  |
| `onValueChange` | `((value: Readonly<Partial<Record<string, "start" \| "end" \| null>>>) => void)` | No |  |

Do not supply: `value`.

### groupingConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `getId` | `(row: TRow) => string` | Yes |  |
| `getLabel` | `(id: string) => string` | Yes |  |
| `leading` | `((id: string) => ReactNode)` | No |  |
| `summary` | `((id: string, pageRows: readonly TRow[]) => ReactNode)` | No |  |
| `collapsedGroups` | `SelectionProps<readonly string[]>` | No |  |

### groupingConfig.collapsedGroups fields

Choose one shape; excluded fields cannot be combined.

#### Option 1

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `value` | `readonly string[]` | Yes |  |
| `onValueChange` | `(value: readonly string[]) => void` | Yes |  |

Do not supply: `defaultValue`.

#### Option 2

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `defaultValue` | `readonly string[]` | No |  |
| `onValueChange` | `((value: readonly string[]) => void)` | No |  |

Do not supply: `value`.

### sortingConfig fields

Choose one shape; excluded fields cannot be combined.

#### Option 1

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `value` | `TableSort \| null` | Yes |  |
| `onValueChange` | `(value: TableSort \| null) => void` | Yes |  |

Do not supply: `defaultValue`.

### sortingConfig.value fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `columnId` | `string` | Yes |  |
| `direction` | `"desc" \| "asc"` | Yes |  |

#### Option 2

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `defaultValue` | `TableSort \| null` | No |  |
| `onValueChange` | `((value: TableSort \| null) => void)` | No |  |

Do not supply: `value`.

### sortingConfig.defaultValue fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `columnId` | `string` | Yes |  |
| `direction` | `"desc" \| "asc"` | Yes |  |

### filterConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `filters` | `readonly TableFilter<TRow>[]` | Yes |  |
| `operators` | `SelectionProps<Readonly<Record<string, TableFilterOperator>>>` | No |  |
| `value` | `Readonly<Record<string, readonly string[]>>` | Yes |  |
| `onValueChange` | `(value: Record<string, readonly string[]>) => void` | Yes |  |

### filterConfig.operators fields

Choose one shape; excluded fields cannot be combined.

#### Option 1

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `value` | `Readonly<Record<string, TableFilterOperator>>` | Yes |  |
| `onValueChange` | `(value: Readonly<Record<string, TableFilterOperator>>) => void` | Yes |  |

Do not supply: `defaultValue`.

#### Option 2

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `defaultValue` | `Readonly<Record<string, TableFilterOperator>>` | No |  |
| `onValueChange` | `((value: Readonly<Record<string, TableFilterOperator>>) => void)` | No |  |

Do not supply: `value`.

### toolbarConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `actions` | `readonly TableAction[]` | No | Prefer named actions. Legacy actions use first=primary, second=secondary, remainder=overflow. |
| `primaryAction` | `(TableAction & { menu?: DropdownMenu; icon?: ReactNode; })` | No |  |
| `secondaryAction` | `TableAction` | No |  |
| `onExport` | `(() => void)` | No |  |
| `moreMenu` | `DropdownMenu` | No |  |

### toolbarConfig.secondaryAction fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes |  |
| `label` | `string` | Yes |  |
| `onAction` | `() => void` | Yes |  |
| `disabled` | `boolean` | No |  |

### toolbarConfig.moreMenu fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `groups` | `DropdownGroup[]` | Yes |  |

### stateConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `isLoading` | `boolean` | No |  |
| `error` | `{ message: string; onRetry: () => void; }` | No |  |
| `empty` | `{ title: string; description?: string; action?: TableAction; }` | No |  |

### stateConfig.error fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `message` | `string` | Yes |  |
| `onRetry` | `() => void` | Yes |  |

### stateConfig.empty fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `title` | `string` | Yes |  |
| `description` | `string` | No |  |
| `action` | `TableAction` | No |  |

### stateConfig.empty.action fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes |  |
| `label` | `string` | Yes |  |
| `onAction` | `() => void` | Yes |  |
| `disabled` | `boolean` | No |  |

### footerConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `total` | `{ label: string; cells: Readonly<Record<string, ReactNode>>; }` | No |  |
| `addAction` | `TableAction` | No |  |

### footerConfig.total fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | Yes |  |
| `cells` | `Readonly<Record<string, ReactNode>>` | Yes |  |

### footerConfig.addAction fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes |  |
| `label` | `string` | Yes |  |
| `onAction` | `() => void` | Yes |  |
| `disabled` | `boolean` | No |  |

### paginationConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `mode` | `"cursor"` | Yes |  |
| `hasNext` | `boolean` | Yes |  |
| `hasPrevious` | `boolean` | Yes |  |
| `onNext` | `() => void` | Yes |  |
| `onPrevious` | `() => void` | Yes |  |
| `summary` | `string` | No |  |
| `pageSize` | `number` | Yes |  |
| `onPageSizeChange` | `((size: number) => void)` | No |  |

All filters have `id`, `label`, optional `icon`, and `initiallyVisible: false` for fields added through More filters. `filterConfig.operators` uses a controlled/uncontrolled map of `is`, `isNot`, `isAnyOf`, `within`, `greaterThan` or `lessThan`; menus expose only operators meaningful for that field type. Range filters use inclusive endpoints. A custom choice `matches` predicate describes positive membership; `isNot` is applied by Table.

| Prop               | Type                                                                   | Behavior                                                                                                          |
| ------------------ | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `label`            | string                                                                 | Required accessible table name                                                                                    |
| `rows`             | readonly TRow[]                                                        | Immutable local collection or one received API page                                                               |
| `columns`          | readonly TableColumn&lt;TRow&gt;[]                                     | IDs, headers, accessors and renderers                                                                             |
| `rowConfig`        | `{ getId, getLabel, onActivate?, menu? }`                              | Stable unique IDs; activation opens the record, checkbox selects it                                               |
| `dataMode`         | `'local'` or `'remote'`                                                | Local by default; remote never reprocesses the received page                                                      |
| `viewConfig`       | `{ density?, hiddenColumns?, columnWidths?, columnPinning? }`          | Each uses controlled value/onValueChange or defaultValue; compact and all columns visible by default              |
| `container`        | `'card'` or `'flat'`                                                   | Card by default                                                                                                   |
| `sortingConfig`    | SelectionProps&lt;TableSort or null&gt;                                | Controlled value/onValueChange or defaultValue                                                                    |
| `searchConfig`     | `{ value?, defaultValue?, onValueChange?, getText?, placeholder? }`    | Enables search; default local searchable text joins column values                                                 |
| `filterConfig`     | `{ filters, value, onValueChange, operators? }`                        | Controlled filter map; typed choice/date/number definitions, optional controlled operators                        |
| `paginationConfig` | Page or cursor configuration                                           | Local page 1 and 25 rows by default                                                                               |
| `selectionConfig`  | SelectionProps plus selection options                                  | Stable IDs, optional eligibility, query-scoped select-all and bulk actions                                        |
| `toolbarConfig`    | `{ primaryAction?, secondaryAction?, onExport?, moreMenu?, actions? }` | App callbacks; exports never imply just the loaded rows                                                           |
| `stateConfig`      | `{ isLoading?, error?, empty? }`                                       | Skeleton rows, retryable failure or empty state                                                                   |
| `summary`          | ReactNode                                                              | Read-only summary inside the container; put app preset controls above Table                                       |
| `footerConfig`     | `{ total?: { label, cells }, addAction? }`                             | App-owned totals keyed by column ID and an explicit add-row action; first visible column contains the total label |
| `className`        | string                                                                 | Root classes                                                                                                      |

`TableColumn` has `id`, string `header`, optional `description` tooltip, `value(row)`, `cell(row)`, `width`, `minWidth`, `maxWidth` in pixels, `resizable`, `pinnable`, `align: 'start' | 'center' | 'end'`, `sortable`, `compare(left, right)`, `hideable`, and `pin: 'start' | 'end'`. `value` supplies sortable/searchable data even when `cell` renders rich content. Pinned columns are grouped at their logical edge; horizontal pinning releases when it would leave insufficient space for a value column. Do not hide every column. Column IDs and row IDs must remain stable, nonempty and unique when updating data. Invalid IDs, empty column collections and invalid page numbers fail with configuration errors.

`viewConfig.density` uses `compact` or `relaxed`; `viewConfig.hiddenColumns` uses column IDs. Control either independently to persist user preferences. Non-hideable columns stay visible, and the first column remains as a safeguard if all hideable columns were requested hidden.

`TableSort` is `{ columnId, direction: 'asc' | 'desc' }`; the sort control cycles ascending, descending, unsorted. Local search/filter/sort run before pagination. Search and sort changes return to the first page. App-controlled filters should reset the page when changed externally.

### Cell renderers [#cell-renderers]

`TableCell` is a discriminated content component; the owning table supplies padding and height:

| `type`            | Content props                                                       |
| ----------------- | ------------------------------------------------------------------- |
| `text` (default)  | `value`, `description`, `leadingIcon`, `badge`, `tooltip`, `struck` |
| `entity` / `file` | `label`, `icon`, optional `description`                             |
| `person`          | Avatar `identity`, optional `description`                           |
| `people`          | `label`, AvatarGroup `people`                                       |
| `badge` / `tag`   | `items` using existing Badge/Tag props                              |
| `boolean`         | `value`, optional accessible `label`                                |
| `progress`        | existing ProgressBar `progress` props                               |
| `link`            | `label`, `linkConfig`                                               |
| `copy`            | `value`, `onCopy`, optional `copyLabel`                             |
| `loading`         | Skeleton content                                                    |
| `number`          | All TableNumberCell props                                           |

`TableNumberCell` accepts `value`, optional `locale`, Intl.NumberFormat `format`, `unit`, `suffix`, `tone`, `struck` and `trend: { direction: 'up' | 'down', intent: 'positive' | 'critical' | 'neutral' }`. Numbers align right without truncation; unit labels stay separate. Empty and non-finite numeric values show an en dash. Numeric strings preserve their supplied formatting. Trend direction does not determine business meaning: rising costs may be critical.

`TableEditCell` accepts `label`, string `value`, `editor`, optional `display`, `outlined`, `disabled`, `validationMessage`, `validate(value)` and async `onCommit(value)`. Editors are `{ type: 'text', placeholder? }`, `{ type: 'number', min?, max?, step?, unit?, suffix? }`, `{ type: 'select', options, placeholder? }`, or `{ type: 'date', min?, max? }`. Number drafts remain strings; validate and parse at the application boundary. Enter or leaving the editor saves; Escape cancels. Failed saves retain the draft and show Retry save. Select/calendar portals belong to the same editing session. DatePicker retains the approved Kakashi DateField padding. DataGrid manages `editingConfig` internally; ordinary Table renderers use the local editing state.

## Design rules [#design-rules]

Figma guidance and reviewed implementation decisions for this component. Rule IDs remain stable for source comments and AI references.

### API references [#api-references]

The API review used [shadcn's Data Table guide](https://ui.shadcn.com/docs/components/base/data-table), [TanStack controlled state](https://tanstack.com/table/v8/docs/framework/react/guide/table-state) and [typed column definitions](https://tanstack.com/table/v8/docs/guide/column-defs). Pathfinder adopts independently controlled state and reusable boundaries while retaining direct props, Figma styling and precompiled CSS. It keeps TanStack 8.21.3 implementation types private; the current shadcn guide's v9 code is not copied into this v8 integration. Matrix keyboard behavior follows the [ARIA grid pattern](https://www.w3.org/WAI/ARIA/apg/patterns/grid/).

### Table [#table]

- **TABLE-001:** White card, radius 12px, pearl-300 hairline 0.5px; optional flat container. Header 36px, compact row minimum 40px, relaxed 52px, horizontal padding 12px. Two-line text rows are 52px compact and 64px relaxed; longer wrapping and accessible controls can grow further. Toolbar and pagination minimum 48px.
- **TABLE-002:** Hover and selected rows share pearl-200. Checkbox distinguishes selection; clicking the row opens the record. Links, editors, menus and checkboxes do not also activate the row. Enter opens a focused activatable row.
- **TABLE-003:** Local processing is filter/search, sort, then page. Remote processing belongs entirely to the app; no double filtering, sorting or slicing. The app owns URL state, fetching, cancellation, permissions, exports and API cursors. Slash opens search while focus is within table tools. Escape clears then closes search. Search opens in place and its clear action clears and closes. Tools remain together; excess filter buttons use +N. Active compound filter chips wrap below the toolbar.
- **TABLE-004:** Selection persists by stable IDs. All-matching selection requires a query key and eligible total, with explicit excluded IDs. Changing the query key invalidates stale all-selection. Bulk actions float 24px above the viewport bottom; three actions show directly and additional actions use overflow.

- **TABLE-005:** Totals are supplied by column ID through footerConfig. The first visible column labels the total; Add line is a separate explicit application action.

- **TABLE-006:** Column width handles support pointer capture, bounded drag, RTL, 10px arrow steps, Shift 50px, Home/End and Escape rollback. Widths and pinning can be controlled independently. Logical pin offsets include selection/action columns and follow resized or hidden columns.
- **TABLE-007:** Group bars use a 2px inset, pearl-100 and radius 6px. Collapse preserves selection. Group only the current page; page counts and select-current-page refer to records, including collapsed descendants.
- **TABLE-008:** Toolbar tools precede overflow and a divider; at most one secondary and one primary action follow. Export becomes an icon when actions are present. Primary plus alternatives uses SplitButton. Bulk actions never move into the ordinary toolbar.
- **FILTER-001:** AND across fields, OR within multiple choices; `isNot` negates membership. Remote tables forward controlled values/operators to the application and never filter the received page.
- **FILTER-002:** Menus align to their trigger at 4px offset. Single choice replaces; multiple choices retain selection while searching and show selected values first. Date presets/ranges reuse DateCalendar. Numeric ranges require valid ascending endpoints. Clear resets only the owning field.
- **FILTER-003:** Option A chips are bordered, radius 6px and minimum 28px. Field/value reopen the editor; operator opens only compatible operators; remove clears that field. Separate chips wrap beneath the toolbar.
- **FILTER-004:** More filters adds a secondary field and opens its editor immediately. +N exposes filters that do not fit. No active field becomes inaccessible when the container narrows.

- **TABLE-009:** Header master `430:13536` / `428:76`: 36px high, Label/12 Strong, 12px icons, 4px gaps, pearl-800 at rest and pearl-1000 for interactive hover/focus. The neutral selector appears on unsorted hover/focus; sorted direction stays visible. End-aligned headers place sorting before the label. Optional information has an independent keyboard-focusable tooltip. Table backgrounds are white; DataGrid headers retain pearl-100 and vertical rules.

### Shared cells [#shared-cells]

- **CELL-001:** Text starts at the logical leading edge; numbers align at the trailing edge and use tabular figures. Empty values show an en dash. Entity flags are 16px, person avatars 20px. Entity names wrap; do not truncate monetary amounts. Cells compose existing Badge, Tag, Avatar, ProgressBar and exact icon assets.
- **CELL-002:** Currency units remain quiet and separate from values. Fixed decimals are supplied via Intl format options. Trend direction and semantic intent are separate. A cost increase can be critical even though its arrow points up.
- **CELL-003:** At most primary bordered icon, secondary icon and overflow in a row action group. Do not repeat text CTAs in every row.
- **CELL-EDIT-001:** Compact text/number editor minimum 28px and radius 6px; calm border at rest, outline on hover/focus, persistent invalid treatment. Existing Select and DatePicker keep their accessible field geometry; the DatePicker retains the user's approved Kakashi DateField padding rather than shrinking its segmented inputs.
- **CELL-EDIT-002:** Enter or leaving the editor commits one cell; Escape cancels. Pending work cannot duplicate; errors retain drafts and offer retry. React portal focus stays within the same editing session. Application code validates and persists canonical values.

The Figma cell master depicts combinations, not 32 separate exported component classes. The shared content family composes those visual patterns. Toggle and upload actions use existing Switch/FileUpload application workflows in custom cells; they do not pretend to be string editors. TableCell content, TableNumberCell, TableRowActions and TableEditCell work in both owning components.

### Explicit differences and boundaries [#explicit-differences-and-boundaries]

- **Selection:** the Table prototype's row-click selection is superseded by its written best practice: row activation opens a record; checkbox selects. The implementation follows that distinction.

- **Accessibility:** quiet text uses pearl-800 for contrast. Long content and text zoom can grow rows. Narrow viewports release horizontal pinning. Copy controls remain visible on touch, and long status labels expose their full text on focus/hover.

- **Application patterns:** presets, summary aggregates, “Saved just now”, network requests, exports, permissions and financial calculations are owned by the app. Demos perform local updates or simulated requests and say so.

- **States and scale:** matrix loading/error fallbacks reuse existing primitives. No virtualization, range operations or spreadsheet formulas are claimed. Stress fixtures do not establish an unlimited-data performance guarantee.

AI consumers should read this page together with the component API and integration pages. Do not infer a public prop from a Figma variant name, or treat a prototype's sample amounts as domain validation rules.

### Header and cell master reconciliation [#header-and-cell-master-reconciliation]

| Reference                                                                                                        | Coverage                                                                                                                                                                 |
| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Header master 430:13536](https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=430-13536)      | Start/center/end alignment, unsorted/ascending/descending, hover/focus, optional information, checkbox and empty action header; Headers, Selection and Pinning examples. |
| [Groups and add row 445:13858](https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=445-13858) | Grouped filings show flags, counts, collapse and page-selection persistence; editable lines show totals and Add line. Application callbacks own actual persistence.      |

Long content and zoom can grow rows beyond Figma's nominal heights. Linear progress and ring progress use their existing Pathfinder primitives; cell recipes do not introduce a new progress implementation. A file-picker example selects a local file and displays its name; production upload, progress and retry belong to the application's upload workflow. These are deliberate compositional boundaries, not built-in backend features.
