Data Table
betaA dense table on Table: sortable columns, people through Person, statuses, peeks, your row, zones, breaks, and quick actions per row.
Preview
Person, sortable, status, peek, rowActions, highlight, onRowClick
| Role | Fees | ||
|---|---|---|---|
| Tom Hale | Designer | 42 | Paid |
| Josh TaitNo reply | Engineer | 17 | $15 due |
| You, Mia Okafor | Writer | 31 | Paid |
| Cal NashDeclined | Analyst | 8 | Paid |
| Ana Ruiz | Support | 25 | $15 due |
Press a row
zones, breaks, highlight in a zone
| # | Name | Closed |
|---|---|---|
| 1 | Mia Okafor | 9 |
| 2 | Cal Nash | 7 |
| Top two | ||
| 3 | Tom Hale | 5 |
| 4 | Ana Ruiz | 4 |
| Your place | ||
| 5 | You, Josh Tait | 2 |
loading, empty
| Name | When |
|---|---|
| Name | When |
|---|---|
| No one has signed up yet | |
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/data-table
Use it for
- Rows of records read across: a team, payments, attendance. Put it in a Card with className="px-0" on the part that holds it, so it runs edge to edge.
- It is generic over the row: every callback sees your row type. Keep columns stable, outside the component or in useMemo, so a sort is not redone each render.
- A field shaped { person, photo } draws as Person. Use render for anything else.
- status puts the row's status right after a cell's value, usually the name. A table's status follows the name, never its own column.
- peek explains a value: who, when, why. Plain text gets a dotted underline.
- sortable adds a header button. The first press sorts text A to Z and numbers high to low, and the next press flips it. aria-sort says which.
- highlight marks your rows with a You tag and their own wash. zones tint a run of rows, like the top places, with a 2px rule where it meets the rest. breaks add a labeled line, like Your place.
- rowActions spring in over the row's end on hover or focus, and their keys work then. If they would cover a peek or a status, the table keeps a band at the end for them.
- hideBelow drops a column while the table is narrow, so the names keep their room.
Avoid it for
- A list of things that are not read across. Use ListRow.
- Sorting a table with zones or breaks. They follow row positions, so a sort moves them onto other rows.
- A column for the status. Put status on the name column.
- Colors alone for a zone. Say what it means in a peek or in words.
Props
- columns
Required. Each: { key, label, short?, width?, align?, numeric?, render?(row, hovered), peek?(row), underline?, status?(row), rowHeader?, sortable?, sortValue?(row), hideBelow?, className? }. short shows in place of label, like "P" for Played, while label is read.
DataColumn<Row>[]
- rows
Required. Long tables draw 100 rows at a time as the reader nears the end.
Row[]
- rowKey
A stable key per row. Default row.id, then the position.
string | ((row, index) => string | number)
- breaks
A labeled line after a row index. Its arrow says whether the label is about the rows above or below.
{ after: number; label: string; tone?; side?: "above" | "below" }[]
- zones
Runs of row indices tinted in a tone as one band, with a 2px rule where the band meets the rest.
{ from: number; to: number; tone? }[]
- highlight
Rows that are yours: a You tag after the name and their own wash, or the tag alone inside a zone.
(row) => boolean
- highlightLabel
The tag on a highlighted row, which screen readers hear before its name.
string · default "You"
- rowActions
Quick actions over the row's end while it is hovered or holds focus.
(row) => HoverAction[]
- onRowClick
Makes each row one press, named by its row header.
(row) => void
- sort
Controlled sort.
{ key: string; direction: "ascending" | "descending" } | null
- defaultSort
The first sort, uncontrolled.
{ key: string; direction: "ascending" | "descending" } | null
- onSortChange
Hears each sort a header button asks for.
(sort) => void
- caption
Names the table, under it.
ReactNode
- aria-label
Names the table when it has no caption.
string
- loading
Holds the space with three placeholder rows.
boolean · default false
- empty
What shows with no rows.
ReactNode · default "Nothing here yet"
- data-slot
The root's slot, for a part built on DataTable, like Leaderboard.
string · default "data-table"
- className
Extra classes on the wrapper. Set --data-table-edge to change the first and last columns' inset. Later classes win.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| columns | DataColumn<Row>[] | - | Required. Each: { key, label, short?, width?, align?, numeric?, render?(row, hovered), peek?(row), underline?, status?(row), rowHeader?, sortable?, sortValue?(row), hideBelow?, className? }. short shows in place of label, like "P" for Played, while label is read. |
| rows | Row[] | - | Required. Long tables draw 100 rows at a time as the reader nears the end. |
| rowKey | string | ((row, index) => string | number) | - | A stable key per row. Default row.id, then the position. |
| breaks | { after: number; label: string; tone?; side?: "above" | "below" }[] | - | A labeled line after a row index. Its arrow says whether the label is about the rows above or below. |
| zones | { from: number; to: number; tone? }[] | - | Runs of row indices tinted in a tone as one band, with a 2px rule where the band meets the rest. |
| highlight | (row) => boolean | - | Rows that are yours: a You tag after the name and their own wash, or the tag alone inside a zone. |
| highlightLabel | string | "You" | The tag on a highlighted row, which screen readers hear before its name. |
| rowActions | (row) => HoverAction[] | - | Quick actions over the row's end while it is hovered or holds focus. |
| onRowClick | (row) => void | - | Makes each row one press, named by its row header. |
| sort | { key: string; direction: "ascending" | "descending" } | null | - | Controlled sort. |
| defaultSort | { key: string; direction: "ascending" | "descending" } | null | - | The first sort, uncontrolled. |
| onSortChange | (sort) => void | - | Hears each sort a header button asks for. |
| caption | ReactNode | - | Names the table, under it. |
| aria-label | string | - | Names the table when it has no caption. |
| loading | boolean | false | Holds the space with three placeholder rows. |
| empty | ReactNode | "Nothing here yet" | What shows with no rows. |
| data-slot | string | "data-table" | The root's slot, for a part built on DataTable, like Leaderboard. |
| className | string | - | Extra classes on the wrapper. Set --data-table-edge to change the first and last columns' inset. Later classes win. |
Accessibility
- A real <table>, with column headers. A column marked rowHeader is a <th scope="row">, so each cell is read with the row's name.
- A sortable header is a button inside the <th>. The sorted header carries aria-sort, ascending or descending.
- With onRowClick, each row's press is one real button named by its row header. Its focus ring goes around the whole row. Controls in the row keep their own presses, and none sits inside another.
- A highlighted row's row header starts with "You," for screen readers, so every cell is read with it. Its tag is words, so it stays in forced colors.
- A zone's edges are 2px borders, so they stay in forced colors. Its meaning needs words, like a peek on its rows.
- Row actions show on hover and on focus inside the row. Their single-key shortcuts work only while the row is lit.
- Each row carries data-peek-row, so a peek opens beside it, and ↑↓ step between peeks.
- While loading, the table is aria-busy.
Examples
Sortable columns with people
type Member = { id: string; name: { person: string }; role: string; projects: number };
const columns: DataColumn<Member>[] = [
{ key: "name", label: "Name", rowHeader: true, sortable: true, sortValue: (row) => row.name.person },
{ key: "role", label: "Role" },
{ key: "projects", label: "Projects", numeric: true, sortable: true },
];
<DataTable
aria-label="Team"
columns={columns}
rows={[
{ id: "p1", name: { person: "Tom Hale" }, role: "Designer", projects: 42 },
{ id: "p2", name: { person: "Josh Tait" }, role: "Engineer", projects: 17 },
]}
/>