AZMARA PLATFORM
Components

Table

A semantic, non-interactive table with a caption, sticky header, striping and a named scroll region. Server-safe.

Usage

import {
  Table, TableCaption, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell,
} from "@azmr/ui/table";
Preview theme
Preview density
Invoices for October
InvoiceStatusAmount
INV-001Paid$250.00
INV-002Pending$1,250.00
INV-003Paid$480.50
INV-004Overdue$90.00
Total$2,070.50
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from "@azmr/ui/table";

const invoices = [
  { id: "INV-001", status: "Paid", amount: 250 },
  { id: "INV-002", status: "Pending", amount: 1250 },
  { id: "INV-003", status: "Paid", amount: 480.5 },
  { id: "INV-004", status: "Overdue", amount: 90 },
];

const nzd = new Intl.NumberFormat("en-NZ", { style: "currency", currency: "NZD" });

export default function TableBasic() {
  const total = invoices.reduce((sum, i) => sum + i.amount, 0);
  return (
    <Table striped hoverable className="max-w-xl">
      <TableCaption>Invoices for October</TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead>Invoice</TableHead>
          <TableHead>Status</TableHead>
          <TableHead align="right">Amount</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {invoices.map((invoice) => (
          <TableRow key={invoice.id}>
            <TableHead scope="row" className="font-normal text-foreground">
              {invoice.id}
            </TableHead>
            <TableCell>{invoice.status}</TableCell>
            <TableCell numeric>{nzd.format(invoice.amount)}</TableCell>
          </TableRow>
        ))}
      </TableBody>
      <TableFooter>
        <TableRow>
          <TableCell colSpan={2}>Total</TableCell>
          <TableCell numeric>{nzd.format(total)}</TableCell>
        </TableRow>
      </TableFooter>
    </Table>
  );
}

Table is a native <table>, so it already has the table, row and column roles with no ARIA added. It is a Server Component: no hooks, no "use client". It replaces hand-built Table* markup (for example MUI's TableCell) with the same structure and sensible styling from tokens.

Name every table with a TableCaption. Use srOnly to keep it for assistive technology and hide it visually.

Header cells

TableHead is a <th>. It defaults to scope="col". For the first cell of a row (a row header), pass scope="row", as in the demo, so a screen reader announces the row's name with each value. Put aria-sort on a header cell when the column is sorted.

Numbers and alignment

TableCell numeric right-aligns and uses tabular figures, so a column of amounts lines up. align (left, center, right) works on both TableHead and TableCell: align a numeric column's header the same way as its cells.

Scrolling and the sticky header

Preview theme
Preview density
Customers, with their city and order count
IDNameCityOrders
CUST-001Aroha NgataHamilton0
CUST-002Tane WalkerAuckland7
CUST-003Mere ParataWellington14
CUST-004Hemi RangiDunedin21
CUST-005Aroha NgataHamilton28
CUST-006Tane WalkerAuckland4
CUST-007Mere ParataWellington11
CUST-008Hemi RangiDunedin18
CUST-009Aroha NgataHamilton25
CUST-010Tane WalkerAuckland1
CUST-011Mere ParataWellington8
CUST-012Hemi RangiDunedin15
CUST-013Aroha NgataHamilton22
CUST-014Tane WalkerAuckland29
CUST-015Mere ParataWellington5
CUST-016Hemi RangiDunedin12
CUST-017Aroha NgataHamilton19
CUST-018Tane WalkerAuckland26
CUST-019Mere ParataWellington2
CUST-020Hemi RangiDunedin9
CUST-021Aroha NgataHamilton16
CUST-022Tane WalkerAuckland23
CUST-023Mere ParataWellington30
CUST-024Hemi RangiDunedin6
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@azmr/ui/table";

const rows = Array.from({ length: 24 }, (_, i) => ({
  id: `CUST-${String(i + 1).padStart(3, "0")}`,
  name: ["Aroha Ngata", "Tane Walker", "Mere Parata", "Hemi Rangi"][i % 4],
  city: ["Hamilton", "Auckland", "Wellington", "Dunedin"][i % 4],
  orders: (i * 7) % 31,
}));

export default function TableSticky() {
  return (
    <Table
      label="Customers (scrollable)"
      stickyHeader
      hoverable
      containerClassName="max-h-64 max-w-xl rounded-md border border-border"
    >
      <TableCaption srOnly>Customers, with their city and order count</TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead>ID</TableHead>
          <TableHead>Name</TableHead>
          <TableHead>City</TableHead>
          <TableHead align="right">Orders</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {rows.map((r) => (
          <TableRow key={r.id}>
            <TableCell>{r.id}</TableCell>
            <TableCell>{r.name}</TableCell>
            <TableCell>{r.city}</TableCell>
            <TableCell numeric>{r.orders}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

A table wider than its space scrolls inside its own container, so it never breaks the page. Give the table a label and the container becomes a named, focusable region, so a keyboard user can scroll it with the arrow keys. Do this for any table that may overflow.

stickyHeader keeps the header row in view while the body scrolls; give the container a height with containerClassName (such as max-h-64). The header cells sit on an opaque background, so rows slide cleanly beneath them.

Striped, hoverable, selected

striped tints every other body row and hoverable tints the row under the pointer. TableRow selected marks a row with data-selected and tints it. These are styling only: a hover tint is not interactive, and a selected row is not announced as selected unless you also make it a control (for example with a checkbox in the row).

Rows follow the density token (--density-row-height), so a table grows or shrinks with data-density.

Not a data grid

This is a presentational table. Sorting, filtering, selection, virtualised rows and editing belong to the data grid (Phase 4), which builds on this.

Props

Prop

Type

Accessibility

  • Native table semantics: table, rowgroup, row, columnheader, rowheader, cell. Nothing is overridden.
  • A caption names the table; row headers use scope="row".
  • The scroll container, when labelled, is a region with a tab stop and a full-strength focus ring.
  • Striping and hover tints are never the only way information is conveyed.
Keyboard interactions
KeyAction
TabMoves to the scroll region when the table has a label. Nothing else in a static table is focusable.
ArrowDownorArrowUporArrowRightorArrowLeftScrolls a focused, labelled region.
PageDownorPageUporHomeorEndScrolls by a page, or to the start or end.

On this page