Skip to content

Table

A simple, accessible table for a small number of rows: optional sorting and row selection. Build it from TableHeader, TableColumn, TableBody, TableRow and TableCell. For thousands of rows, filtering, editing or grouping, use DataGrid.

Datav0.1.0experimentalWCAG 2.2 AAView spec
NameRoleCity
Ada LovelaceMathematicianLondon
Grace HopperRear admiralNew York
Katherine JohnsonMathematicianHampton
import { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from "@rdloom/react";

const people = [
  { id: "ada", name: "Ada Lovelace", role: "Mathematician", city: "London" },
  { id: "grace", name: "Grace Hopper", role: "Rear admiral", city: "New York" },
  { id: "katherine", name: "Katherine Johnson", role: "Mathematician", city: "Hampton" },
];

// isRowHeader marks the column that names each row, so a screen reader can say
// "Grace Hopper, Role, Rear admiral" for a cell.
export default function TableBasicExample() {
  return (
    <div className="w-[34rem] max-w-full">
      <Table label="People">
        <TableHeader>
          <TableColumn isRowHeader>Name</TableColumn>
          <TableColumn>Role</TableColumn>
          <TableColumn>City</TableColumn>
        </TableHeader>
        <TableBody>
          {people.map((p) => (
            <TableRow key={p.id} id={p.id}>
              <TableCell>{p.name}</TableCell>
              <TableCell>{p.role}</TableCell>
              <TableCell>{p.city}</TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}

Installation

npx rdloom add table

Copies the source into src/components/rdloom. Edit it freely: rdloom upgrade merges later versions into your changes. It needs react-aria-components; add --install to install them.

Use another registry client
npx shadcn@latest add https://rdloom.vimalbhatt.com/r/table.json

Works, but without upgrade tracking.

Usage

import { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from "@rdloom/react";

<div className="w-[34rem] max-w-full">
  <Table label="People">
    <TableHeader>
      <TableColumn isRowHeader>Name</TableColumn>
      <TableColumn>Role</TableColumn>
      <TableColumn>City</TableColumn>
    </TableHeader>
    <TableBody>
      {people.map((p) => (
        <TableRow key={p.id} id={p.id}>
          <TableCell>{p.name}</TableCell>
          <TableCell>{p.role}</TableCell>
          <TableCell>{p.city}</TableCell>
        </TableRow>
      ))}
    </TableBody>
  </Table>
</div>

Sortable

CustomerAmount
Acme$1,200
Initech$980
Globex$340
Hooli$15
import { useMemo, useState } from "react";
import { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow, type SortDescriptor } from "@rdloom/react";

const invoices = [
  { id: "inv-1042", customer: "Acme", amount: 1200 },
  { id: "inv-1043", customer: "Globex", amount: 340 },
  { id: "inv-1044", customer: "Initech", amount: 980 },
  { id: "inv-1045", customer: "Hooli", amount: 15 },
];

// The table reports the sort (ascending, descending, then none); you sort your data and pass it back.
export default function TableSortableExample() {
  const [sort, setSort] = useState<SortDescriptor | null>({ column: "amount", direction: "descending" });
  // A third press on a column clears the sort (null): the rows go back to their own order.
  const rows = useMemo(() => {
    if (!sort) return invoices;
    const key = sort.column as "customer" | "amount";
    const sorted = [...invoices].sort((a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0));
    return sort.direction === "descending" ? sorted.reverse() : sorted;
  }, [sort]);
  return (
    <div className="w-[28rem] max-w-full">
      <Table label="Invoices" sortDescriptor={sort} onSortChange={setSort}>
        <TableHeader>
          <TableColumn id="customer" isRowHeader allowsSorting>
            Customer
          </TableColumn>
          <TableColumn id="amount" allowsSorting>
            Amount
          </TableColumn>
        </TableHeader>
        <TableBody>
          {rows.map((r) => (
            <TableRow key={r.id} id={r.id}>
              <TableCell>{r.customer}</TableCell>
              <TableCell>${r.amount.toLocaleString("en-US")}</TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}

Selectable

NameSize
report.pdf1.2 MB
photo.png840 KB
notes.txt4 KB

1 selected

import { useState } from "react";
import { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from "@rdloom/react";

const files = [
  { id: "a", name: "report.pdf", size: "1.2 MB" },
  { id: "b", name: "photo.png", size: "840 KB" },
  { id: "c", name: "notes.txt", size: "4 KB" },
];

export default function TableSelectableExample() {
  const [selected, setSelected] = useState<"all" | Set<unknown>>(new Set(["b"]));
  const count = selected === "all" ? files.length : selected.size;
  return (
    <div className="flex w-[28rem] max-w-full flex-col gap-2 text-sm">
      <Table label="Files" selectionMode="multiple" selectedKeys={selected as Set<string>} onSelectionChange={(keys) => setSelected(keys)}>
        <TableHeader>
          <TableColumn isRowHeader>Name</TableColumn>
          <TableColumn>Size</TableColumn>
        </TableHeader>
        <TableBody>
          {files.map((f) => (
            <TableRow key={f.id} id={f.id}>
              <TableCell>{f.name}</TableCell>
              <TableCell>{f.size}</TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
      <p aria-live="polite">{count} selected</p>
    </div>
  );
}

API Reference

Defined by the spec. Components also accept the props of the React Aria component they wrap.

PropTypeDefault
labelrequired

Accessible name of the table, e.g. 'Invoices'.

stringnone
childrenrequired

A TableHeader and a TableBody.

nodenone
density

Row height.

"compact" | "standard" | "comfortable""standard"
selectionMode

Adds a checkbox column. Selecting changes aria-selected on the row.

"none" | "single" | "multiple""none"
selectedKeys

Controlled selection, by row id.

"all" | Iterable<Key>none
defaultSelectedKeys

Initial selection when uncontrolled.

"all" | Iterable<Key>none
onSelectionChange

Called with the selected row ids.

(keys: "all" | Set<Key>) => voidnone
sortDescriptor

Controlled sort: the column id and direction, or null for the default order. You sort the rows yourself.

SortDescriptor | nullnone
onSortChange

Called when the user presses a column with allowsSorting. A column goes ascending, then descending, then back to the default order: null on that third press. Sort your data and pass it back.

(descriptor: SortDescriptor | null) => voidnone

Accessibility

Role grid, WCAG 2.2 AA. Tested with axe and keyboard tests; screen reader checks are in the audit checklist.

Keyboard

  • Tab moves into the table, then out
  • Arrow keys move between cells
  • Enter or Space on a sortable column header sorts it
  • Space on a row toggles its selection when selection is on

Screen readers announce

  • Announced as a table with its name, row and column counts
  • Moving across a row announces each column header with the cell; the row header names the row
  • Sorting announces the new sort direction on the header

What your code must do

  • A named table with column headers and one row-header column (isRowHeader) so each cell is announced with its row
  • Sortable columns expose aria-sort and say so in the header, not only with an arrow
  • Selected rows expose aria-selected, with a labelled checkbox in each row and a select-all in the header
  • Rows keep the order of your data: sorting is done by you, then announced by the header

Guidelines

Use it when

  • A short list of records, up to a few dozen rows
  • Tabular data that needs to read well for screen reader users
  • Lightweight sorting or row selection

Avoid it when

  • Thousands of rows, column filters, editing, grouping or export: use DataGrid
  • Page layout: never use a table for layout
  • A few items with rich content: use cards or a list

Don't

  • No row header column (set isRowHeader on the column that names the row)
  • Sorting the data without passing the new sortDescriptor back
  • Using a table for layout

Design tokens

The semantic tokens this component uses. Change them once and every component follows; see Design tokens.

  • --rd-color-surface-default
  • --rd-color-surface-subtle
  • --rd-color-surface-selected
  • --rd-color-border-default
  • --rd-color-text-default
  • --rd-color-text-muted
  • --rd-color-action-primary
  • --rd-color-focus-ring