Skip to content

Table component

Table is a config-driven data table: pass `columns` and `rows` and get sorting, row selection, row expansion, a sticky header, and built-in loading and empty states. On narrow viewports it falls back to stacked cards.

Data Display18 propsAccessibility notes

Playground

Change a prop on the right and the preview updates. Switch to Code to copy what you see.

Recipes

Copy-paste patterns for the ways Table actually gets used — each one a real, self-contained example.

Basic table

Pass columns and rows and you get a real, semantic <table> with a styled header — plus a stacked-card fallback on narrow viewports.

<Table
  aria-label="Team members"
  columns={[
    { key: "name", header: "Name" },
    { key: "email", header: "Email" },
    { key: "role", header: "Role" },
  ]}
  rows={rows}
  getRowKey={(r) => r.id}
/>

Sortable columns

Mark a column sortable and its header becomes a sort button with aria-sort. With no onSortChange, Table sorts client-side for you.

<Table
  aria-label="Team members"
  columns={[
    { key: "name", header: "Name", sortable: true },
    { key: "role", header: "Role" },
    { key: "mrr", header: "MRR", align: "end", sortable: true },
  ]}
  rows={rows}
  getRowKey={(r) => r.id}
  defaultSortDescriptor={{ column: "mrr", direction: "descending" }}
/>

Row selection

selectionMode="multiple" adds labelled, keyboard-operable checkboxes; keep the selection in your own state via selectedKeys/onSelectionChange.

const [selected, setSelected] = useState(new Set());

<Table
  aria-label="Team members"
  columns={columns}
  rows={rows}
  getRowKey={(r) => r.id}
  selectionMode="multiple"
  selectedKeys={selected}
  onSelectionChange={(keys) => setSelected(keys)}
/>

Custom cell rendering

A column's render function returns any node for that cell — a status Chip, a formatted number, an avatar — while sorting still works off the raw row value.

<Table
  aria-label="Invoices"
  columns={[
    { key: "id", header: "Invoice" },
    {
      key: "status",
      header: "Status",
      render: (row) => (
        <Chip
          variant="soft"
          color={row.status === "Paid" ? "success" : "warning"}
        >
          {row.status}
        </Chip>
      ),
    },
    { key: "amount", header: "Amount", align: "end" },
  ]}
  rows={rows}
  getRowKey={(r) => r.id}
/>

Loading state

isLoading swaps the body for skeleton rows (loadingRowCount controls how many), so the layout never jumps when the real data arrives.

<Table
  aria-label="Team members"
  columns={columns}
  rows={[]}
  getRowKey={(r) => r.id}
  isLoading
  loadingRowCount={4}
/>

Example

table.tsx
<Table
  columns={[
    { key: "name", header: "Name", sortable: true },
    { key: "plan", header: "Plan" },
    { key: "mrr", header: "MRR", align: "end", sortable: true },
  ]}
  rows={customers}
  selectable
  onSelectionChange={setSelected}
/>

Props

The full public API for Table. Every prop is stable and intentionally typed.

Data

columns

TableColumn<Row>[]

default: —

{ key, header, align?, sortable?, sortValue?, render?, width?, headerClassName?, cellClassName? }.

rows

Row[]

default: —

The data.

getRowKey

(row, index) => string | number

default: —

Stable key — use an id field, not the index.

aria-label

string

default: —

Required.

Sorting

sortDescriptor / defaultSortDescriptor

{ column, direction } | null

default: null

Current / initial sort.

onSortChange

(descriptor) => void

default: —

Present → server-side sort; omit for client-side.

Selection

selectionMode

"none" | "single" | "multiple"

default: none

Row selection.

selectedKeys / defaultSelectedKeys / onSelectionChange

Iterable<Key> / …

default: —

Controlled / uncontrolled selection.

isRowSelectable

(row) => boolean

default: —

Disable selection per row.

Expansion

renderExpandedRow

(row) => ReactNode

default: —

Adds the chevron expand-toggle column.

expandedKeys / defaultExpandedKeys / onExpandedChange

Iterable<Key> / …

default: —

Controlled expansion.

Behavior & states

isLoading / loadingRowCount

boolean / number

default: false / 5

Skeleton rows while loading.

emptyContent

ReactNode

default: <EmptyState title="No data" />

Shown when rows is empty.

onRowAction

(key, row) => void

default: —

Makes rows clickable (click / Enter / Space).

Layout & positioning

stickyHeader / maxHeight

boolean / number | string

default: false / —

Sticky header + vertical scroll container.

isCompact / isStriped / removeWrapper

boolean

default: false

Density and framing.

topContent / bottomContent

ReactNode

default: —

Toolbar / pagination slots.

Styling

classNames

TableClassNames

default: —

Per-slot class overrides.

Accessibility

  • Renders a real `<table>` with `<th scope>` headers; sortable headers are buttons with `aria-sort`.
  • The select-all and per-row checkboxes are labelled and keyboard operable.
  • Loading and empty states replace the body with a labelled status region, not a blank table.