Components Data display
Table
StableSemantic table primitives with density, striping, sticky headers, sorting and accessible row links.
Use Table to present structured data people compare across rows and columns. It renders native table markup, so screen readers announce headers with every cell. For built-in sorting, selection and pagination from a data array, use DataTable; for spreadsheet-style editing use DataGrid.
Overview
| Name | Status | Signatures | |
|---|---|---|---|
| Amara Okafor | amara@binds.si | Deployed | 3 |
| Lukas Novak | lukas@binds.si | Pending | 1 |
| Priya Raman | priya@binds.si | Deployed | 2 |
| Tomás Ferreira | tomas@binds.si | Failed | 0 |
| Total | 6 | ||
import { Badge, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } from '@bindshq/stance-react';
const users = [
{
name: 'Amara Okafor',
email: 'amara@binds.si',
department: 'Sales',
status: 'Deployed',
signatures: 3,
},
{
name: 'Lukas Novak',
email: 'lukas@binds.si',
department: 'Engineering',
status: 'Pending',
signatures: 1,
},
{
name: 'Priya Raman',
email: 'priya@binds.si',
department: 'Legal',
status: 'Deployed',
signatures: 2,
},
{
name: 'Tomás Ferreira',
email: 'tomas@binds.si',
department: 'Support',
status: 'Failed',
signatures: 0,
},
{
name: 'Hana Sato',
email: 'hana@binds.si',
department: 'Marketing',
status: 'Deployed',
signatures: 4,
},
{
name: 'Oliver Grant',
email: 'oliver@binds.si',
department: 'Finance',
status: 'Pending',
signatures: 1,
},
];
const tone = { Deployed: 'success', Pending: 'warning', Failed: 'danger' } as const;
export function Basic() {
return (
<Table>
<TableCaption>Organisation users</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
<TableHead align="end">Signatures</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.slice(0, 4).map((u) => (
<TableRow key={u.email}>
<TableCell>{u.name}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>
<Badge tone={tone[u.status as keyof typeof tone]} dot>
{u.status}
</Badge>
</TableCell>
<TableCell numeric>{u.signatures}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={3}>Total</TableCell>
<TableCell numeric>6</TableCell>
</TableRow>
</TableFooter>
</Table>
);
}Light, dark and high contrast
Every Stance component is designed and tested in all three appearance modes.
| Name | Status | Signatures | |
|---|---|---|---|
| Amara Okafor | amara@binds.si | Deployed | 3 |
| Lukas Novak | lukas@binds.si | Pending | 1 |
| Priya Raman | priya@binds.si | Deployed | 2 |
| Tomás Ferreira | tomas@binds.si | Failed | 0 |
| Total | 6 | ||
| Name | Status | Signatures | |
|---|---|---|---|
| Amara Okafor | amara@binds.si | Deployed | 3 |
| Lukas Novak | lukas@binds.si | Pending | 1 |
| Priya Raman | priya@binds.si | Deployed | 2 |
| Tomás Ferreira | tomas@binds.si | Failed | 0 |
| Total | 6 | ||
| Name | Status | Signatures | |
|---|---|---|---|
| Amara Okafor | amara@binds.si | Deployed | 3 |
| Lukas Novak | lukas@binds.si | Pending | 1 |
| Priya Raman | priya@binds.si | Deployed | 2 |
| Tomás Ferreira | tomas@binds.si | Failed | 0 |
| Total | 6 | ||
Import
import { Table, TableCaption, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableRowLink } from '@bindshq/stance-react';Platforms
Table has native counterparts. They share Stance tokens and intent, and use each platform’s own controls and conventions. The live previews on this page are the web implementation.
import { Table, TableCaption, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableRowLink } from '@bindshq/stance-react';Playground
| Name | Department | Signatures |
|---|---|---|
| Amara Okafor | Sales | 3 |
| Lukas Novak | Engineering | 1 |
| Priya Raman | Legal | 2 |
| Tomás Ferreira | Support | 0 |
<Table />Examples
Sortable columns
TableHead renders a sort button and sets aria-sort; you own the sorting logic.
| Department | ||
|---|---|---|
| Amara Okafor | Sales | 3 |
| Hana Sato | Marketing | 4 |
| Lukas Novak | Engineering | 1 |
| Oliver Grant | Finance | 1 |
| Priya Raman | Legal | 2 |
| Tomás Ferreira | Support | 0 |
import * as React from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@bindshq/stance-react';
import type { SortDirection } from '@bindshq/stance-react';
const users = [
{
name: 'Amara Okafor',
email: 'amara@binds.si',
department: 'Sales',
status: 'Deployed',
signatures: 3,
},
{
name: 'Lukas Novak',
email: 'lukas@binds.si',
department: 'Engineering',
status: 'Pending',
signatures: 1,
},
{
name: 'Priya Raman',
email: 'priya@binds.si',
department: 'Legal',
status: 'Deployed',
signatures: 2,
},
{
name: 'Tomás Ferreira',
email: 'tomas@binds.si',
department: 'Support',
status: 'Failed',
signatures: 0,
},
{
name: 'Hana Sato',
email: 'hana@binds.si',
department: 'Marketing',
status: 'Deployed',
signatures: 4,
},
{
name: 'Oliver Grant',
email: 'oliver@binds.si',
department: 'Finance',
status: 'Pending',
signatures: 1,
},
];
export function Sortable() {
const [sort, setSort] = React.useState<{ key: 'name' | 'signatures'; dir: SortDirection }>({
key: 'name',
dir: 'ascending',
});
const rows = [...users].sort((a, b) => {
const r = sort.key === 'name' ? a.name.localeCompare(b.name) : a.signatures - b.signatures;
return sort.dir === 'descending' ? -r : r;
});
const toggle = (key: 'name' | 'signatures') =>
setSort((s) => ({
key,
dir: s.key === key && s.dir === 'ascending' ? 'descending' : 'ascending',
}));
return (
<Table aria-label="Users sorted">
<TableHeader>
<TableRow>
<TableHead
sortDirection={sort.key === 'name' ? sort.dir : 'none'}
onSort={() => toggle('name')}
>
Name
</TableHead>
<TableHead>Department</TableHead>
<TableHead
align="end"
sortDirection={sort.key === 'signatures' ? sort.dir : 'none'}
onSort={() => toggle('signatures')}
>
Signatures
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((u) => (
<TableRow key={u.email}>
<TableCell>{u.name}</TableCell>
<TableCell>{u.department}</TableCell>
<TableCell numeric>{u.signatures}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}Clickable rows
Put a TableRowLink in the row. Its hit area covers the row, while keyboard and screen-reader users reach a real link.
| Template | Assigned | Last edited |
|---|---|---|
| Sales 2026 | 48 people | 2 days ago |
| Executive | 6 people | Last week |
| Support | 22 people | 3 weeks ago |
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableRowLink } from '@bindshq/stance-react';
export function InteractiveRows() {
return (
<Table aria-label="Signature templates">
<TableHeader>
<TableRow>
<TableHead>Template</TableHead>
<TableHead>Assigned</TableHead>
<TableHead align="end">Last edited</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[
['Sales 2026', '48 people', '2 days ago'],
['Executive', '6 people', 'Last week'],
['Support', '22 people', '3 weeks ago'],
].map(([name, assigned, edited]) => (
<TableRow key={name} interactive>
<TableCell>
<TableRowLink href={`#template-${name}`}>{name}</TableRowLink>
</TableCell>
<TableCell>{assigned}</TableCell>
<TableCell align="end">{edited}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}| Contract | Counterparty | Value |
|---|---|---|
| MSA-2026-014 | Northwind Ltd | £48,000 |
| NDA-2026-102 | Contoso GmbH | None |
| SOW-2026-031 | Fabrikam Inc | £12,500 |
| Contract | Counterparty | Value |
|---|---|---|
| MSA-2026-014 | Northwind Ltd | £48,000 |
| NDA-2026-102 | Contoso GmbH | None |
| SOW-2026-031 | Fabrikam Inc | £12,500 |
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@bindshq/stance-react';
export function Density() {
return (
<div style={{ display: 'grid', gap: 24 }}>
{(['compact', 'comfortable'] as const).map((d) => (
<Table key={d} density={d} striped aria-label={`${d} table`}>
<TableHeader>
<TableRow>
<TableHead>Contract</TableHead>
<TableHead>Counterparty</TableHead>
<TableHead align="end">Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>MSA-2026-014</TableCell>
<TableCell>Northwind Ltd</TableCell>
<TableCell numeric>£48,000</TableCell>
</TableRow>
<TableRow selected>
<TableCell>NDA-2026-102</TableCell>
<TableCell>Contoso GmbH</TableCell>
<TableCell numeric>None</TableCell>
</TableRow>
<TableRow>
<TableCell>SOW-2026-031</TableCell>
<TableCell>Fabrikam Inc</TableCell>
<TableCell numeric>£12,500</TableCell>
</TableRow>
</TableBody>
</Table>
))}
</div>
);
}Sticky header
Set maxHeight to scroll the body while keeping column headers visible.
| Name | Department | Status |
|---|---|---|
| Amara Okafor | Sales | Deployed |
| Lukas Novak | Engineering | Pending |
| Priya Raman | Legal | Deployed |
| Tomás Ferreira | Support | Failed |
| Hana Sato | Marketing | Deployed |
| Oliver Grant | Finance | Pending |
| Amara Okafor | Sales | Deployed |
| Lukas Novak | Engineering | Pending |
| Priya Raman | Legal | Deployed |
| Tomás Ferreira | Support | Failed |
| Hana Sato | Marketing | Deployed |
| Oliver Grant | Finance | Pending |
| Amara Okafor | Sales | Deployed |
| Lukas Novak | Engineering | Pending |
| Priya Raman | Legal | Deployed |
| Tomás Ferreira | Support | Failed |
| Hana Sato | Marketing | Deployed |
| Oliver Grant | Finance | Pending |
| Amara Okafor | Sales | Deployed |
| Lukas Novak | Engineering | Pending |
| Priya Raman | Legal | Deployed |
| Tomás Ferreira | Support | Failed |
| Hana Sato | Marketing | Deployed |
| Oliver Grant | Finance | Pending |
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@bindshq/stance-react';
const users = [
{
name: 'Amara Okafor',
email: 'amara@binds.si',
department: 'Sales',
status: 'Deployed',
signatures: 3,
},
{
name: 'Lukas Novak',
email: 'lukas@binds.si',
department: 'Engineering',
status: 'Pending',
signatures: 1,
},
{
name: 'Priya Raman',
email: 'priya@binds.si',
department: 'Legal',
status: 'Deployed',
signatures: 2,
},
{
name: 'Tomás Ferreira',
email: 'tomas@binds.si',
department: 'Support',
status: 'Failed',
signatures: 0,
},
{
name: 'Hana Sato',
email: 'hana@binds.si',
department: 'Marketing',
status: 'Deployed',
signatures: 4,
},
{
name: 'Oliver Grant',
email: 'oliver@binds.si',
department: 'Finance',
status: 'Pending',
signatures: 1,
},
];
export function StickyHeader() {
const many = Array.from({ length: 4 }, () => users).flat();
return (
<Table stickyHeader maxHeight={260} density="compact" scrollLabel="All users">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Department</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{many.map((u, i) => (
<TableRow key={i}>
<TableCell>{u.name}</TableCell>
<TableCell>{u.department}</TableCell>
<TableCell>{u.status}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}API reference
Table
| Prop | Type | Default | Description |
|---|---|---|---|
density | 'compact' | 'default' | 'comfortable' | 'default' | Cell padding and row height. |
striped | boolean | false | Zebra-stripe body rows. |
stickyHeader | boolean | false | Keep headers visible while scrolling vertically. |
bordered | boolean | false | Vertical lines between columns. |
maxHeight | number | string | None | Max height of the scroll container. |
scrollLabel | string | None | Accessible name of the focusable scroll region. Falls back to aria-label, then "Table". |
wrapperClassName | string | None | Class for the scroll wrapper. |
TableCaption
| Prop | Type | Default | Description |
|---|---|---|---|
visuallyHidden | boolean | false | Keep the caption for screen readers only. |
TableRow
| Prop | Type | Default | Description |
|---|---|---|---|
selected | boolean | false | Highlight a selected row. |
interactive | boolean | false | Stretch the row’s TableRowLink over the whole row. |
TableHead
| Prop | Type | Default | Description |
|---|---|---|---|
sortDirection | 'ascending' | 'descending' | 'none' | None | Current sort; renders a sort button and aria-sort. |
onSort | () => void | None | Called when the sort button is activated. |
align | 'start' | 'center' | 'end' | 'start' | Text alignment; use end for numbers. |
TableCell
| Prop | Type | Default | Description |
|---|---|---|---|
align | 'start' | 'center' | 'end' | None | Text alignment. |
numeric | boolean | false | Right-align with tabular numerals. |
TableRowLink
| Prop | Type | Default | Description |
|---|---|---|---|
...props | AnchorHTMLAttributes | None | A real link whose hit area covers an interactive row. |
Accessibility
- Uses native table, thead, tbody, th and td elements, so headers are announced with each cell.
- TableHead sets scope="col" by default. Use scope="row" on row headers.
- The table sits in a focusable region (tabindex 0, role="region") so keyboard users can scroll wide tables. Give it a name with scrollLabel or aria-label.
- Sortable headers render a real button and set aria-sort to ascending, descending or none.
- Clickable rows use a real link (TableRowLink) instead of onClick on <tr>, which keyboards and screen readers cannot reach.
- Other controls inside an interactive row stay above the stretched link and remain independently clickable.
Keyboard
| Keys | Action |
|---|---|
| Tab | Moves to the scroll region, sort buttons and links inside the table. |
| Arrow keys | Scroll the focused region horizontally and vertically. |
| Enter Space | Activates a sort button. |
Usage guidelines
Do
- Right-align numeric columns and use numeric cells.
- Add a caption, visible or visually hidden.
Don’t
- Use tables for page layout.
- Put onClick on rows without a real link or button.