# Table

> Semantic table primitives with density, striping, sticky headers, sorting and accessible row links.

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

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.

## Import

```tsx
import { Table, TableCaption, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableRowLink } 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, 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>
  );
}
```

### Sortable columns

TableHead renders a sort button and sets aria-sort; you own the sorting logic.

```tsx
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.

```tsx
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>
  );
}
```

### Density and striping

```tsx
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.

```tsx
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

| Component | Prop | Type | Default | Description |
| --- | --- | --- | --- | --- |
| Table | `density` | `'compact' \| 'default' \| 'comfortable'` | `'default'` | Cell padding and row height. |
| Table | `striped` | `boolean` | `false` | Zebra-stripe body rows. |
| Table | `stickyHeader` | `boolean` | `false` | Keep headers visible while scrolling vertically. |
| Table | `bordered` | `boolean` | `false` | Vertical lines between columns. |
| Table | `maxHeight` | `number \| string` | None | Max height of the scroll container. |
| Table | `scrollLabel` | `string` | None | Accessible name of the focusable scroll region. Falls back to aria-label, then "Table". |
| Table | `wrapperClassName` | `string` | None | Class for the scroll wrapper. |
| TableCaption | `visuallyHidden` | `boolean` | `false` | Keep the caption for screen readers only. |
| TableRow | `selected` | `boolean` | `false` | Highlight a selected row. |
| TableRow | `interactive` | `boolean` | `false` | Stretch the row’s TableRowLink over the whole row. |
| TableHead | `sortDirection` | `'ascending' \| 'descending' \| 'none'` | None | Current sort; renders a sort button and aria-sort. |
| TableHead | `onSort` | `() => void` | None | Called when the sort button is activated. |
| TableHead | `align` | `'start' \| 'center' \| 'end'` | `'start'` | Text alignment; use end for numbers. |
| TableCell | `align` | `'start' \| 'center' \| 'end'` | None | Text alignment. |
| TableCell | `numeric` | `boolean` | `false` | Right-align with tabular numerals. |
| TableRowLink | `...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. |

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

## Related

- https://stanceui.com/components/data-table
- https://stanceui.com/components/data-grid
- https://stanceui.com/components/pagination
- https://stanceui.com/components/description-list
