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.
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 tableCopies 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.jsonWorks, 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
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
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.
| Prop | Type | Default |
|---|---|---|
labelrequiredAccessible name of the table, e.g. 'Invoices'. | string | none |
childrenrequiredA TableHeader and a TableBody. | node | none |
densityRow height. | "compact" | "standard" | "comfortable" | "standard" |
selectionModeAdds a checkbox column. Selecting changes aria-selected on the row. | "none" | "single" | "multiple" | "none" |
selectedKeysControlled selection, by row id. | "all" | Iterable<Key> | none |
defaultSelectedKeysInitial selection when uncontrolled. | "all" | Iterable<Key> | none |
onSelectionChangeCalled with the selected row ids. | (keys: "all" | Set<Key>) => void | none |
sortDescriptorControlled sort: the column id and direction, or null for the default order. You sort the rows yourself. | SortDescriptor | null | none |
onSortChangeCalled 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) => void | none |
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