# DataTable

> A table for records with sorting, row selection, pagination, loading and empty states.

Category: Advanced · Status: stable · Package: @bindshq/stance-react@0.1.1 · Docs: https://stanceui.com/components/data-table

Use DataTable to present collections people scan, compare and act on, such as organisation users, signature deployments or contracts. It composes the semantic Table primitives, so it stays a real HTML table for assistive technology. For spreadsheet-style editing and very large datasets, use DataGrid.

## Import

```tsx
import { DataTable } from '@bindshq/stance-react';
```

Styles are imported once per app: `@bindshq/stance-tokens/css` and `@bindshq/stance-react/styles.css`.

## Examples

### Basic

```tsx
import { Badge, DataTable } from '@bindshq/stance-react';
import type { DataTableColumn } from '@bindshq/stance-react';

interface OrgUser {
  id: string;
  name: string;
  email: string;
  team: string;
  signature: 'Live' | 'Pending' | 'Failed';
  deployments: number;
}

const users: OrgUser[] = [
  {
    id: 'u1',
    name: 'Priya Nair',
    email: 'priya.nair@binds.si',
    team: 'Sales',
    signature: 'Live',
    deployments: 14,
  },
  {
    id: 'u2',
    name: 'Amara Okafor',
    email: 'amara.okafor@binds.si',
    team: 'Legal',
    signature: 'Live',
    deployments: 6,
  },
  {
    id: 'u3',
    name: 'Lukas Brenner',
    email: 'lukas.brenner@binds.si',
    team: 'Sales',
    signature: 'Pending',
    deployments: 0,
  },
  {
    id: 'u4',
    name: 'Hana Sato',
    email: 'hana.sato@binds.si',
    team: 'Finance',
    signature: 'Live',
    deployments: 9,
  },
  {
    id: 'u5',
    name: 'Tomás Ruiz',
    email: 'tomas.ruiz@binds.si',
    team: 'Operations',
    signature: 'Failed',
    deployments: 2,
  },
  {
    id: 'u6',
    name: 'Owen Price',
    email: 'owen.price@binds.si',
    team: 'Sales',
    signature: 'Live',
    deployments: 11,
  },
  {
    id: 'u7',
    name: 'Zainab Haddad',
    email: 'zainab.haddad@binds.si',
    team: 'Legal',
    signature: 'Pending',
    deployments: 1,
  },
];

const tone = { Live: 'success', Pending: 'warning', Failed: 'danger' } as const;

const columns: DataTableColumn<OrgUser>[] = [
  { id: 'name', header: 'Name', accessor: 'name', sortable: true, rowHeader: true },
  { id: 'email', header: 'Email', accessor: 'email' },
  { id: 'team', header: 'Team', accessor: 'team', sortable: true },
  {
    id: 'signature',
    header: 'Signature',
    accessor: 'signature',
    sortable: true,
    cell: (u) => (
      <Badge tone={tone[u.signature]} variant="soft">
        {u.signature}
      </Badge>
    ),
  },
  {
    id: 'deployments',
    header: 'Deployments',
    accessor: 'deployments',
    sortable: true,
    align: 'end',
  },
];

export function Basic() {
  return (
    <DataTable caption="Organisation users" columns={columns} data={users} getRowId={(u) => u.id} />
  );
}
```

### Row selection

A checkbox column with an indeterminate select-all. Rows can opt out with isRowSelectable.

```tsx
import * as React from 'react';
import { Badge, Button, DataTable } from '@bindshq/stance-react';
import type { DataTableColumn } from '@bindshq/stance-react';

interface OrgUser {
  id: string;
  name: string;
  email: string;
  team: string;
  signature: 'Live' | 'Pending' | 'Failed';
  deployments: number;
}

const users: OrgUser[] = [
  {
    id: 'u1',
    name: 'Priya Nair',
    email: 'priya.nair@binds.si',
    team: 'Sales',
    signature: 'Live',
    deployments: 14,
  },
  {
    id: 'u2',
    name: 'Amara Okafor',
    email: 'amara.okafor@binds.si',
    team: 'Legal',
    signature: 'Live',
    deployments: 6,
  },
  {
    id: 'u3',
    name: 'Lukas Brenner',
    email: 'lukas.brenner@binds.si',
    team: 'Sales',
    signature: 'Pending',
    deployments: 0,
  },
  {
    id: 'u4',
    name: 'Hana Sato',
    email: 'hana.sato@binds.si',
    team: 'Finance',
    signature: 'Live',
    deployments: 9,
  },
  {
    id: 'u5',
    name: 'Tomás Ruiz',
    email: 'tomas.ruiz@binds.si',
    team: 'Operations',
    signature: 'Failed',
    deployments: 2,
  },
  {
    id: 'u6',
    name: 'Owen Price',
    email: 'owen.price@binds.si',
    team: 'Sales',
    signature: 'Live',
    deployments: 11,
  },
  {
    id: 'u7',
    name: 'Zainab Haddad',
    email: 'zainab.haddad@binds.si',
    team: 'Legal',
    signature: 'Pending',
    deployments: 1,
  },
];

const tone = { Live: 'success', Pending: 'warning', Failed: 'danger' } as const;

const columns: DataTableColumn<OrgUser>[] = [
  { id: 'name', header: 'Name', accessor: 'name', sortable: true, rowHeader: true },
  { id: 'email', header: 'Email', accessor: 'email' },
  { id: 'team', header: 'Team', accessor: 'team', sortable: true },
  {
    id: 'signature',
    header: 'Signature',
    accessor: 'signature',
    sortable: true,
    cell: (u) => (
      <Badge tone={tone[u.signature]} variant="soft">
        {u.signature}
      </Badge>
    ),
  },
  {
    id: 'deployments',
    header: 'Deployments',
    accessor: 'deployments',
    sortable: true,
    align: 'end',
  },
];

export function Selection() {
  const [selected, setSelected] = React.useState<string[]>(['u2']);
  return (
    <DataTable
      caption="Organisation users"
      columns={columns}
      data={users}
      getRowId={(u) => u.id}
      selectable
      selectedRowIds={selected}
      onSelectionChange={setSelected}
      getRowLabel={(u) => u.name}
      isRowSelectable={(u) => u.signature !== 'Failed'}
      footer={
        <Button size="sm" variant="outline" disabled={selected.length === 0}>
          Redeploy {selected.length || ''} signature{selected.length === 1 ? '' : 's'}
        </Button>
      }
    />
  );
}
```

### Pagination

Set pageSize to paginate on the client. Page changes are announced politely.

```tsx
import { Badge, DataTable } from '@bindshq/stance-react';
import type { DataTableColumn } from '@bindshq/stance-react';

interface OrgUser {
  id: string;
  name: string;
  email: string;
  team: string;
  signature: 'Live' | 'Pending' | 'Failed';
  deployments: number;
}

const users: OrgUser[] = [
  {
    id: 'u1',
    name: 'Priya Nair',
    email: 'priya.nair@binds.si',
    team: 'Sales',
    signature: 'Live',
    deployments: 14,
  },
  {
    id: 'u2',
    name: 'Amara Okafor',
    email: 'amara.okafor@binds.si',
    team: 'Legal',
    signature: 'Live',
    deployments: 6,
  },
  {
    id: 'u3',
    name: 'Lukas Brenner',
    email: 'lukas.brenner@binds.si',
    team: 'Sales',
    signature: 'Pending',
    deployments: 0,
  },
  {
    id: 'u4',
    name: 'Hana Sato',
    email: 'hana.sato@binds.si',
    team: 'Finance',
    signature: 'Live',
    deployments: 9,
  },
  {
    id: 'u5',
    name: 'Tomás Ruiz',
    email: 'tomas.ruiz@binds.si',
    team: 'Operations',
    signature: 'Failed',
    deployments: 2,
  },
  {
    id: 'u6',
    name: 'Owen Price',
    email: 'owen.price@binds.si',
    team: 'Sales',
    signature: 'Live',
    deployments: 11,
  },
  {
    id: 'u7',
    name: 'Zainab Haddad',
    email: 'zainab.haddad@binds.si',
    team: 'Legal',
    signature: 'Pending',
    deployments: 1,
  },
];

const tone = { Live: 'success', Pending: 'warning', Failed: 'danger' } as const;

const columns: DataTableColumn<OrgUser>[] = [
  { id: 'name', header: 'Name', accessor: 'name', sortable: true, rowHeader: true },
  { id: 'email', header: 'Email', accessor: 'email' },
  { id: 'team', header: 'Team', accessor: 'team', sortable: true },
  {
    id: 'signature',
    header: 'Signature',
    accessor: 'signature',
    sortable: true,
    cell: (u) => (
      <Badge tone={tone[u.signature]} variant="soft">
        {u.signature}
      </Badge>
    ),
  },
  {
    id: 'deployments',
    header: 'Deployments',
    accessor: 'deployments',
    sortable: true,
    align: 'end',
  },
];

export function Paginated() {
  return (
    <DataTable
      caption="Organisation users"
      columns={columns}
      data={users}
      getRowId={(u) => u.id}
      pageSize={3}
      density="compact"
    />
  );
}
```

### Controlled sorting

```tsx
import * as React from 'react';
import { Badge, DataTable } from '@bindshq/stance-react';
import type { DataTableColumn, DataTableSort } from '@bindshq/stance-react';

interface OrgUser {
  id: string;
  name: string;
  email: string;
  team: string;
  signature: 'Live' | 'Pending' | 'Failed';
  deployments: number;
}

const users: OrgUser[] = [
  {
    id: 'u1',
    name: 'Priya Nair',
    email: 'priya.nair@binds.si',
    team: 'Sales',
    signature: 'Live',
    deployments: 14,
  },
  {
    id: 'u2',
    name: 'Amara Okafor',
    email: 'amara.okafor@binds.si',
    team: 'Legal',
    signature: 'Live',
    deployments: 6,
  },
  {
    id: 'u3',
    name: 'Lukas Brenner',
    email: 'lukas.brenner@binds.si',
    team: 'Sales',
    signature: 'Pending',
    deployments: 0,
  },
  {
    id: 'u4',
    name: 'Hana Sato',
    email: 'hana.sato@binds.si',
    team: 'Finance',
    signature: 'Live',
    deployments: 9,
  },
  {
    id: 'u5',
    name: 'Tomás Ruiz',
    email: 'tomas.ruiz@binds.si',
    team: 'Operations',
    signature: 'Failed',
    deployments: 2,
  },
  {
    id: 'u6',
    name: 'Owen Price',
    email: 'owen.price@binds.si',
    team: 'Sales',
    signature: 'Live',
    deployments: 11,
  },
  {
    id: 'u7',
    name: 'Zainab Haddad',
    email: 'zainab.haddad@binds.si',
    team: 'Legal',
    signature: 'Pending',
    deployments: 1,
  },
];

const tone = { Live: 'success', Pending: 'warning', Failed: 'danger' } as const;

const columns: DataTableColumn<OrgUser>[] = [
  { id: 'name', header: 'Name', accessor: 'name', sortable: true, rowHeader: true },
  { id: 'email', header: 'Email', accessor: 'email' },
  { id: 'team', header: 'Team', accessor: 'team', sortable: true },
  {
    id: 'signature',
    header: 'Signature',
    accessor: 'signature',
    sortable: true,
    cell: (u) => (
      <Badge tone={tone[u.signature]} variant="soft">
        {u.signature}
      </Badge>
    ),
  },
  {
    id: 'deployments',
    header: 'Deployments',
    accessor: 'deployments',
    sortable: true,
    align: 'end',
  },
];

export function ControlledSort() {
  const [sort, setSort] = React.useState<DataTableSort | null>({
    columnId: 'deployments',
    direction: 'descending',
  });
  return (
    <div style={{ display: 'grid', gap: 12 }}>
      <p style={{ margin: 0 }}>
        Sorted by: {sort ? `${sort.columnId} (${sort.direction})` : 'none'}
      </p>
      <DataTable
        caption="Most active senders"
        columns={columns}
        data={users}
        getRowId={(u) => u.id}
        sort={sort}
        onSortChange={setSort}
      />
    </div>
  );
}
```

### Loading and empty states

```tsx
import { Badge, DataTable } from '@bindshq/stance-react';
import type { DataTableColumn } from '@bindshq/stance-react';

interface OrgUser {
  id: string;
  name: string;
  email: string;
  team: string;
  signature: 'Live' | 'Pending' | 'Failed';
  deployments: number;
}

const users: OrgUser[] = [
  {
    id: 'u1',
    name: 'Priya Nair',
    email: 'priya.nair@binds.si',
    team: 'Sales',
    signature: 'Live',
    deployments: 14,
  },
  {
    id: 'u2',
    name: 'Amara Okafor',
    email: 'amara.okafor@binds.si',
    team: 'Legal',
    signature: 'Live',
    deployments: 6,
  },
  {
    id: 'u3',
    name: 'Lukas Brenner',
    email: 'lukas.brenner@binds.si',
    team: 'Sales',
    signature: 'Pending',
    deployments: 0,
  },
  {
    id: 'u4',
    name: 'Hana Sato',
    email: 'hana.sato@binds.si',
    team: 'Finance',
    signature: 'Live',
    deployments: 9,
  },
  {
    id: 'u5',
    name: 'Tomás Ruiz',
    email: 'tomas.ruiz@binds.si',
    team: 'Operations',
    signature: 'Failed',
    deployments: 2,
  },
  {
    id: 'u6',
    name: 'Owen Price',
    email: 'owen.price@binds.si',
    team: 'Sales',
    signature: 'Live',
    deployments: 11,
  },
  {
    id: 'u7',
    name: 'Zainab Haddad',
    email: 'zainab.haddad@binds.si',
    team: 'Legal',
    signature: 'Pending',
    deployments: 1,
  },
];

const tone = { Live: 'success', Pending: 'warning', Failed: 'danger' } as const;

const columns: DataTableColumn<OrgUser>[] = [
  { id: 'name', header: 'Name', accessor: 'name', sortable: true, rowHeader: true },
  { id: 'email', header: 'Email', accessor: 'email' },
  { id: 'team', header: 'Team', accessor: 'team', sortable: true },
  {
    id: 'signature',
    header: 'Signature',
    accessor: 'signature',
    sortable: true,
    cell: (u) => (
      <Badge tone={tone[u.signature]} variant="soft">
        {u.signature}
      </Badge>
    ),
  },
  {
    id: 'deployments',
    header: 'Deployments',
    accessor: 'deployments',
    sortable: true,
    align: 'end',
  },
];

export function States() {
  return (
    <div style={{ display: 'grid', gap: 24 }}>
      <DataTable caption="Loading users" columns={columns} data={[]} loading loadingRows={3} />
      <DataTable
        caption="Suspended users"
        columns={columns}
        data={[]}
        emptyState="No suspended users in this organisation."
      />
    </div>
  );
}
```

## API reference

| Component | Prop | Type | Default | Description |
| --- | --- | --- | --- | --- |
| DataTable | `columns` (required) | `DataTableColumn<T>[]` | None | Column definitions: id, header, accessor or cell, sortable, sortFn, align, width, rowHeader. |
| DataTable | `data` (required) | `readonly T[]` | None | The rows to display. |
| DataTable | `caption` (required) | `ReactNode` | None | Names the table. Use captionHidden to hide it visually. |
| DataTable | `getRowId` | `(row: T, index: number) => string` | `index` | Stable id per row, used for selection and keys. |
| DataTable | `captionHidden` | `boolean` | `false` | Visually hide the caption while keeping it for screen readers. |
| DataTable | `sort` | `DataTableSort \| null` | None | Controlled sort: { columnId, direction }. |
| DataTable | `defaultSort` | `DataTableSort \| null` | `null` | Initial sort when uncontrolled. |
| DataTable | `onSortChange` | `(sort: DataTableSort \| null) => void` | None | Called when a sortable header cycles ascending, descending, none. |
| DataTable | `selectable` | `boolean` | `false` | Adds a selection checkbox column. |
| DataTable | `selectedRowIds` | `string[]` | None | Controlled selection. |
| DataTable | `defaultSelectedRowIds` | `string[]` | `[]` | Initial selection when uncontrolled. |
| DataTable | `onSelectionChange` | `(ids: string[]) => void` | None | Called with the selected row ids. |
| DataTable | `isRowSelectable` | `(row: T) => boolean` | None | Disable selection for some rows. |
| DataTable | `getRowLabel` | `(row: T) => string` | None | Names each row in its checkbox label, e.g. “Select Priya Nair”. |
| DataTable | `pageSize` | `number` | None | Rows per page. Omit to show every row. |
| DataTable | `page` | `number` | None | Controlled 1-based page. |
| DataTable | `defaultPage` | `number` | `1` | Initial page when uncontrolled. |
| DataTable | `onPageChange` | `(page: number) => void` | None | Called when the page changes. |
| DataTable | `loading` | `boolean` | `false` | Shows skeleton rows and sets aria-busy on the table. |
| DataTable | `loadingRows` | `number` | `5` | Number of skeleton rows. |
| DataTable | `emptyState` | `ReactNode` | `“No results”` | Rendered in a full-width cell when there are no rows. |
| DataTable | `footer` | `ReactNode` | None | Content beside the range summary, such as bulk actions. |
| DataTable | `density` | `'compact' \| 'default' \| 'comfortable'` | `'default'` | Row height and cell padding. |
| DataTable | `striped / stickyHeader / maxHeight` | `boolean / boolean / number \| string` | None | Passed through to Table. |
| DataTable | `scrollLabel` | `string` | None | Label for the scroll region. Defaults to the caption when it is a string. |

## Accessibility

- Renders a native <table> with a <caption>, so screen readers announce its name, size and headers.
- Sortable headers contain real buttons and expose aria-sort on the column header.
- Mark the identifying column with rowHeader so each row is announced by name.
- Selection checkboxes are labelled per row (“Select Priya Nair”). The select-all checkbox reports the mixed state.
- Sorting, selection and page changes are announced through a polite live region.
- While loading, the table sets aria-busy and the skeleton rows are hidden from assistive technology.
- The table scrolls inside a focusable, labelled region, so wide tables never break the page.

## Keyboard

| Keys | Action |
| --- | --- |
| `Tab` | Moves between sort buttons, checkboxes, row actions and pagination. |
| `Enter` `Space` | Activates a sort button. Cycles ascending, descending, none. |
| `Space` | Toggles a selection checkbox. |

## Guidelines

**Do**

- Right-align numeric columns and use tabular figures.
- Keep bulk actions visible in the footer while rows are selected.
- Paginate or filter long collections rather than rendering thousands of rows.

**Don’t**

- Put onClick on rows. Make the primary column a link instead.
- Use DataTable for in-place editing; use DataGrid.

## Related

- https://stanceui.com/components/table
- https://stanceui.com/components/data-grid
- https://stanceui.com/components/pagination
- https://stanceui.com/components/filter-bar
- https://stanceui.com/components/checkbox
