Table

A list of records in a bordered card, one row per record.

Overview

Records that share columns, where a person compares values down a column.

Records

Do
Quotes
ClientPremium
Naledi DlaminiR 1 249.00
Pieter van WykR 880.50
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeaderCell,
  TableRow,
} from "@hackcove/ui";

<Table caption="Quotes">
  <TableHead>
    <TableRow>
      <TableHeaderCell>Client</TableHeaderCell>
      <TableHeaderCell align="end">Premium</TableHeaderCell>
    </TableRow>
  </TableHead>
  <TableBody>
    <TableRow>
      <TableCell tone="strong">Naledi Dlamini</TableCell>
      <TableCell align="end" numeric>
        R 1 249.00
      </TableCell>
    </TableRow>
    <TableRow>
      <TableCell tone="strong">Pieter van Wyk</TableCell>
      <TableCell align="end" numeric>
        R 880.50
      </TableCell>
    </TableRow>
  </TableBody>
</Table>
A table is a head and a body: names in strong cells, amounts in align="end" numeric cells so the figures line up.
Don't
Quotes
ClientPremium
Naledi DlaminiR 1 249.00
Pieter van WykR 880.50
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeaderCell,
  TableRow,
} from "@hackcove/ui";

<Table caption="Quotes">
  <TableHead>
    <TableRow>
      <TableHeaderCell>Client</TableHeaderCell>
      <TableHeaderCell>Premium</TableHeaderCell>
    </TableRow>
  </TableHead>
  <TableBody>
    <TableRow>
      <TableCell>Naledi Dlamini</TableCell>
      <TableCell>R 1 249.00</TableCell>
    </TableRow>
    <TableRow>
      <TableCell>Pieter van Wyk</TableCell>
      <TableCell>R 880.50</TableCell>
    </TableRow>
  </TableBody>
</Table>
Amounts in plain start-aligned cells do not line up, so the eye cannot compare them.

Totals

Do
Budget
LineSpent
GroceriesR 4 120.00
Eating outR 2 980.00
TotalR 7 100.00
import {
  Table,
  TableBody,
  TableCell,
  TableFoot,
  TableHead,
  TableHeaderCell,
  TableRow,
} from "@hackcove/ui";

<Table caption="Budget">
  <TableHead>
    <TableRow>
      <TableHeaderCell>Line</TableHeaderCell>
      <TableHeaderCell align="end">Spent</TableHeaderCell>
    </TableRow>
  </TableHead>
  <TableBody>
    <TableRow>
      <TableCell>Groceries</TableCell>
      <TableCell align="end" numeric>
        R 4 120.00
      </TableCell>
    </TableRow>
    <TableRow>
      <TableCell>Eating out</TableCell>
      <TableCell align="end" numeric>
        R 2 980.00
      </TableCell>
    </TableRow>
  </TableBody>
  <TableFoot>
    <TableRow>
      <TableCell>Total</TableCell>
      <TableCell align="end" numeric>
        R 7 100.00
      </TableCell>
    </TableRow>
  </TableFoot>
</Table>
Totals go in a TableFoot after the body: strong cells and a firmer line above them.
Don't
Budget
LineSpent
GroceriesR 4 120.00
Eating outR 2 980.00
TotalR 7 100.00
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeaderCell,
  TableRow,
} from "@hackcove/ui";

<Table caption="Budget">
  <TableHead>
    <TableRow>
      <TableHeaderCell>Line</TableHeaderCell>
      <TableHeaderCell align="end">Spent</TableHeaderCell>
    </TableRow>
  </TableHead>
  <TableBody>
    <TableRow>
      <TableCell>Groceries</TableCell>
      <TableCell align="end" numeric>
        R 4 120.00
      </TableCell>
    </TableRow>
    <TableRow>
      <TableCell>Eating out</TableCell>
      <TableCell align="end" numeric>
        R 2 980.00
      </TableCell>
    </TableRow>
    <TableRow>
      <TableCell tone="strong">Total</TableCell>
      <TableCell align="end" numeric tone="strong">
        R 7 100.00
      </TableCell>
    </TableRow>
  </TableBody>
</Table>
A total faked as one more body row, in a strong cell, reads like a line item and is not a <tfoot>.

Row status

Do
Debit orders
Debit orderStatusAmount
InsurancePaidR 1 850.00
GymFailedR 349.00
import {
  Pill,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeaderCell,
  TableRow,
} from "@hackcove/ui";

<Table caption="Debit orders">
  <TableHead>
    <TableRow>
      <TableHeaderCell>Debit order</TableHeaderCell>
      <TableHeaderCell>Status</TableHeaderCell>
      <TableHeaderCell align="end">Amount</TableHeaderCell>
    </TableRow>
  </TableHead>
  <TableBody>
    <TableRow>
      <TableCell tone="strong">Insurance</TableCell>
      <TableCell>Paid</TableCell>
      <TableCell align="end" numeric>
        R 1 850.00
      </TableCell>
    </TableRow>
    <TableRow tone="danger">
      <TableCell tone="strong">Gym</TableCell>
      <TableCell>
        <Pill tone="red">Failed</Pill>
      </TableCell>
      <TableCell align="end" numeric>
        R 349.00
      </TableCell>
    </TableRow>
  </TableBody>
</Table>
tone marks the row's status with a stripe and a soft fill, and a Pill in a cell names the status in words.
Don't
Debit orders
Debit orderAmount
InsuranceR 1 850.00
GymR 349.00
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeaderCell,
  TableRow,
} from "@hackcove/ui";

<Table caption="Debit orders">
  <TableHead>
    <TableRow>
      <TableHeaderCell>Debit order</TableHeaderCell>
      <TableHeaderCell align="end">Amount</TableHeaderCell>
    </TableRow>
  </TableHead>
  <TableBody>
    <TableRow>
      <TableCell tone="strong">Insurance</TableCell>
      <TableCell align="end" numeric>
        R 1 850.00
      </TableCell>
    </TableRow>
    <TableRow tone="danger">
      <TableCell tone="strong">Gym</TableCell>
      <TableCell align="end" numeric>
        R 349.00
      </TableCell>
    </TableRow>
  </TableBody>
</Table>
A toned row whose cells never say what the tone means: colour alone tells nobody who cannot see it.

Stripe

Do
  • Changed
  • Removed
Pages
PageStatus
/pricingChanged
/old-landingRemoved
import {
  Legend,
  Stack,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeaderCell,
  TableRow,
} from "@hackcove/ui";

<Stack gap="xs">
  <Legend
    items={[
      { label: "Changed", tone: "warn" },
      { label: "Removed", tone: "danger" },
    ]}
  />
  <Table caption="Pages">
    <TableHead>
      <TableRow>
        <TableHeaderCell>Page</TableHeaderCell>
        <TableHeaderCell>Status</TableHeaderCell>
      </TableRow>
    </TableHead>
    <TableBody>
      <TableRow stripe="warn">
        <TableCell tone="strong">/pricing</TableCell>
        <TableCell>Changed</TableCell>
      </TableRow>
      <TableRow stripe="danger">
        <TableCell tone="strong">/old-landing</TableCell>
        <TableCell>Removed</TableCell>
      </TableRow>
    </TableBody>
  </Table>
</Stack>
stripe marks a row's status by colour alone and keeps its hover; a Legend and a status word in the row say what each colour means.
Don't
Pages
Page
/pricing
/old-landing
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeaderCell,
  TableRow,
} from "@hackcove/ui";

<Table caption="Pages">
  <TableHead>
    <TableRow>
      <TableHeaderCell>Page</TableHeaderCell>
    </TableRow>
  </TableHead>
  <TableBody>
    <TableRow stripe="warn">
      <TableCell tone="strong">/pricing</TableCell>
    </TableRow>
    <TableRow stripe="danger">
      <TableCell tone="strong">/old-landing</TableCell>
    </TableRow>
  </TableBody>
</Table>
Stripes with no key and no status word in the row: the colour is the only signal, and it means nothing to anyone who cannot tell the colours apart.

Actions column

Do
Clients
ClientActions
Naledi Dlamini
import {
  Button,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeaderCell,
  TableRow,
} from "@hackcove/ui";

<Table caption="Clients">
  <TableHead>
    <TableRow>
      <TableHeaderCell>Client</TableHeaderCell>
      <TableHeaderCell align="end" srLabel="Actions" />
    </TableRow>
  </TableHead>
  <TableBody>
    <TableRow>
      <TableCell tone="strong">Naledi Dlamini</TableCell>
      <TableCell align="end">
        <Button variant="outline" size="sm">
          Open
        </Button>
      </TableCell>
    </TableRow>
  </TableBody>
</Table>
A row's actions get a column of their own whose head has an srLabel: the head keeps its column and a screen reader hears "Actions".
Don't
Clients
Client
Naledi Dlamini
import {
  Button,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeaderCell,
  TableRow,
} from "@hackcove/ui";

<Table caption="Clients">
  <TableHead>
    <TableRow>
      <TableHeaderCell>Client</TableHeaderCell>
      <TableHeaderCell align="end" />
    </TableRow>
  </TableHead>
  <TableBody>
    <TableRow>
      <TableCell tone="strong">Naledi Dlamini</TableCell>
      <TableCell align="end">
        <Button variant="outline" size="sm">
          Open
        </Button>
      </TableCell>
    </TableRow>
  </TableBody>
</Table>
An empty header cell leaves the column with no name, so a screen reader hears a column of unlabelled buttons.

Usage

  • Compose a Table from TableHead, TableBody and, for totals, TableFoot. A TableHead holds one TableRow of TableHeaderCells; give the Table a caption that names it.
  • Right-align a column of amounts with align="end" on its header and its cells, and set numeric on the cells so the figures line up. A code or a type is mono.
  • A cell's tone is default, strong for the name of the row, muted for the secondary column, or a Callout tone for an amount that carries a meaning; pair the tone with a sign or a word. secondary is a muted second line under a cell.
  • Totals and subtotals are TableFoot rows, never body rows. A TableFoot row takes no tone.
  • Mark a row's status with tone (a stripe and a soft fill, or muted to fade the row) or with stripe (the stripe alone, in the StackedBar tones, which keeps the row's hover). Give one of them, never both, and name the status in a cell as well (a Pill), or draw a Legend for the colours.
  • Group rows with a TableGroupRow and give it colSpan equal to the table's column count; trailing is a count or a subtotal at the far end. A full-width note or empty state is a TableCell with colSpan.
  • A column with nothing to say in its head (its actions) takes a TableHeaderCell with srLabel="Actions". variant="plain" drops the card, for a table inside a Card.
  • A prose cell wraps; a numeric or mono cell never breaks. truncate cuts a long value to one line and keeps its full text as a title; its content is plain text.
  • To make a whole row clickable, put one router <Link> with rowLinkClass in its first cell. Keep an action that is not navigation a Button in an actions column.

Accessibility

  • A Table renders a native <table>; caption is its accessible name, visually hidden.
  • A TableHeaderCell is a <th scope="col">; a TableGroupRow is a <th scope="rowgroup">.
  • srLabel is hidden text inside the header cell, so an actions column still has a name for a screen reader.
  • Colour alone never carries a row's or a cell's status; name it in text.
  • A truncate cell puts its full text in a title.
  • A rowLinkClass link is a real link and the only tab stop in its row; the row's whole area is its click target.
  • On a narrow screen the card scrolls sideways instead of squeezing its columns.

API

Table

A list of records in a rounded, bordered card that scrolls sideways on a narrow screen. Compose it from TableHead, TableBody, TableFoot, TableRow, TableHeaderCell and TableCell. caption names the table for assistive tech and is not drawn. variant="plain" drops the card (its border, fill and shadow), the head's tinted strip and the row hover, and keeps the row dividers; it is for a table that sits inside a card of its own. Other props (id, data-*, aria-*) go on the <table>.


caption
Type
string

variant
Type
"card" | "plain"
Default
card

TableHead

The <thead>: small uppercase column titles, on a tinted strip unless the table is plain. Holds one TableRow of TableHeaderCells.

TableBody

The <tbody>: the rows, each with a line above it and, unless the table is plain, a fill on hover.

TableFoot

The <tfoot>: totals and subtotals. Its cells are strong by default and each row has a firmer line above it, in a card and in a plain table alike. Put it after TableBody; a row of it takes no tone.

TableRow

A <tr>, relative: the anchor a rowLinkClass link stretches to. Use it in the head and the body. In the body it also draws the line above the row and, unless the table is plain, the hover fill. tone marks the row's status (see RowTone): a stripe on the leading edge and a soft fill, or muted to fade it. A toned row keeps its fill on hover (the status stays readable), so a linked row's link, not a hover fill, says it is clickable. stripe is the stripe alone, in the TextTone colours (the names a StackedBar segment takes), with no fill: the row keeps its hover and its own look. Give tone or stripe, not both. Body rows only; colour alone does not say it, so name the status in a cell too (a Pill). A bold subtotal is not a tone: it is a TableFoot row.


stripe
Type
"positive" | "danger" | "info" | "warn" | "muted"

tone
Type
"positive" | "danger" | "neutral" | "info" | "warn" | "muted"

TableHeaderCell

A column title, <th scope="col">. A column with nothing to show in its head (a row's actions) takes srLabel instead of children: the name a screen reader hears ("Actions"), visually hidden so the head keeps its column.


align
Type
"end" | "start"
Default
start

srLabel
Type
string

TableCell

A <td>. tone is the text weight and colour (see CellTone; in a TableFoot it defaults to strong); numeric sets tabular figures, mono a small mono face. secondary is a second, muted line under the content. One rule for wrapping: prose wraps onto more lines (never narrower than min-w-48, so it scrolls the table before it becomes a sliver), and a numeric or mono cell (a figure, a code, a type) never breaks. truncate caps a long value's width, cuts it with an ellipsis on one line and puts the full text in a title; the content is then plain text (strings and numbers), since that is what the title shows. colSpan passes through: a cell that spans the row is a full-width panel (an empty state, a note under a group).


align
Type
"end" | "start"
Default
start

mono
Type
boolean
Default
false

numeric
Type
boolean
Default
false

secondary
Type
ReactNode

tone
Type
"strong" | "default" | "positive" | "danger" | "neutral" | "info" | "warn" | "muted"

truncate
Type
boolean
Default
false

TableGroupRow

A full-width group header in the body: a tinted row with a label that spans the table's colSpan columns (its real column count), and optional trailing content at the far end (a count, a subtotal, a small button). The group's rows follow it. For a panel of other content across the whole row, use a TableCell with colSpan instead.


colSpan required
Type
number

label required
Type
ReactNode

trailing
Type
ReactNode

Functions

  • rowLinkClass The one link in a row that stretches over the whole row, for a router's own <Link> in the row's first cell; a plain link is a TextLink.