Components Advanced
DataTable
StableA table for records with sorting, row selection, pagination, loading and empty states.
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.
Overview
| Priya Nair | priya.nair@binds.si | Sales | Live | 14 |
|---|---|---|---|---|
| Amara Okafor | amara.okafor@binds.si | Legal | Live | 6 |
| Lukas Brenner | lukas.brenner@binds.si | Sales | Pending | 0 |
| Hana Sato | hana.sato@binds.si | Finance | Live | 9 |
| Tomás Ruiz | tomas.ruiz@binds.si | Operations | Failed | 2 |
| Owen Price | owen.price@binds.si | Sales | Live | 11 |
| Zainab Haddad | zainab.haddad@binds.si | Legal | Pending | 1 |
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} />
);
}Light, dark and high contrast
Every Stance component is designed and tested in all three appearance modes.
| Priya Nair | priya.nair@binds.si | Sales | Live | 14 |
|---|---|---|---|---|
| Amara Okafor | amara.okafor@binds.si | Legal | Live | 6 |
| Lukas Brenner | lukas.brenner@binds.si | Sales | Pending | 0 |
| Hana Sato | hana.sato@binds.si | Finance | Live | 9 |
| Tomás Ruiz | tomas.ruiz@binds.si | Operations | Failed | 2 |
| Owen Price | owen.price@binds.si | Sales | Live | 11 |
| Zainab Haddad | zainab.haddad@binds.si | Legal | Pending | 1 |
| Priya Nair | priya.nair@binds.si | Sales | Live | 14 |
|---|---|---|---|---|
| Amara Okafor | amara.okafor@binds.si | Legal | Live | 6 |
| Lukas Brenner | lukas.brenner@binds.si | Sales | Pending | 0 |
| Hana Sato | hana.sato@binds.si | Finance | Live | 9 |
| Tomás Ruiz | tomas.ruiz@binds.si | Operations | Failed | 2 |
| Owen Price | owen.price@binds.si | Sales | Live | 11 |
| Zainab Haddad | zainab.haddad@binds.si | Legal | Pending | 1 |
| Priya Nair | priya.nair@binds.si | Sales | Live | 14 |
|---|---|---|---|---|
| Amara Okafor | amara.okafor@binds.si | Legal | Live | 6 |
| Lukas Brenner | lukas.brenner@binds.si | Sales | Pending | 0 |
| Hana Sato | hana.sato@binds.si | Finance | Live | 9 |
| Tomás Ruiz | tomas.ruiz@binds.si | Operations | Failed | 2 |
| Owen Price | owen.price@binds.si | Sales | Live | 11 |
| Zainab Haddad | zainab.haddad@binds.si | Legal | Pending | 1 |
Import
import { DataTable } from '@bindshq/stance-react';Examples
Row selection
A checkbox column with an indeterminate select-all. Rows can opt out with isRowSelectable.
| Priya Nair | priya.nair@binds.si | Sales | Live | 14 | |
|---|---|---|---|---|---|
| Amara Okafor | amara.okafor@binds.si | Legal | Live | 6 | |
| Lukas Brenner | lukas.brenner@binds.si | Sales | Pending | 0 | |
| Hana Sato | hana.sato@binds.si | Finance | Live | 9 | |
| Tomás Ruiz | tomas.ruiz@binds.si | Operations | Failed | 2 | |
| Owen Price | owen.price@binds.si | Sales | Live | 11 | |
| Zainab Haddad | zainab.haddad@binds.si | Legal | Pending | 1 |
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.
| Priya Nair | priya.nair@binds.si | Sales | Live | 14 |
|---|---|---|---|---|
| Amara Okafor | amara.okafor@binds.si | Legal | Live | 6 |
| Lukas Brenner | lukas.brenner@binds.si | Sales | Pending | 0 |
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"
/>
);
}Sorted by: deployments (descending)
| Priya Nair | priya.nair@binds.si | Sales | Live | 14 |
|---|---|---|---|---|
| Owen Price | owen.price@binds.si | Sales | Live | 11 |
| Hana Sato | hana.sato@binds.si | Finance | Live | 9 |
| Amara Okafor | amara.okafor@binds.si | Legal | Live | 6 |
| Tomás Ruiz | tomas.ruiz@binds.si | Operations | Failed | 2 |
| Zainab Haddad | zainab.haddad@binds.si | Legal | Pending | 1 |
| Lukas Brenner | lukas.brenner@binds.si | Sales | Pending | 0 |
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>
);
}| No suspended users in this organisation. | ||||
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
| Prop | Type | Default | Description |
|---|---|---|---|
columns* required | DataTableColumn<T>[] | None | Column definitions: id, header, accessor or cell, sortable, sortFn, align, width, rowHeader. |
data* required | readonly T[] | None | The rows to display. |
caption* required | ReactNode | None | Names the table. Use captionHidden to hide it visually. |
getRowId | (row: T, index: number) => string | index | Stable id per row, used for selection and keys. |
captionHidden | boolean | false | Visually hide the caption while keeping it for screen readers. |
sort | DataTableSort | null | None | Controlled sort: { columnId, direction }. |
defaultSort | DataTableSort | null | null | Initial sort when uncontrolled. |
onSortChange | (sort: DataTableSort | null) => void | None | Called when a sortable header cycles ascending, descending, none. |
selectable | boolean | false | Adds a selection checkbox column. |
selectedRowIds | string[] | None | Controlled selection. |
defaultSelectedRowIds | string[] | [] | Initial selection when uncontrolled. |
onSelectionChange | (ids: string[]) => void | None | Called with the selected row ids. |
isRowSelectable | (row: T) => boolean | None | Disable selection for some rows. |
getRowLabel | (row: T) => string | None | Names each row in its checkbox label, e.g. “Select Priya Nair”. |
pageSize | number | None | Rows per page. Omit to show every row. |
page | number | None | Controlled 1-based page. |
defaultPage | number | 1 | Initial page when uncontrolled. |
onPageChange | (page: number) => void | None | Called when the page changes. |
loading | boolean | false | Shows skeleton rows and sets aria-busy on the table. |
loadingRows | number | 5 | Number of skeleton rows. |
emptyState | ReactNode | “No results” | Rendered in a full-width cell when there are no rows. |
footer | ReactNode | None | Content beside the range summary, such as bulk actions. |
density | 'compact' | 'default' | 'comfortable' | 'default' | Row height and cell padding. |
striped / stickyHeader / maxHeight | boolean / boolean / number | string | None | Passed through to Table. |
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. |
Usage 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.