Table
A presentational table with sortable heads, aligned number columns, a caption, and a footer.
Table is the semantic <table> markup in a set of parts: header, body, footer, row, head,
cell, and caption. It holds no data and no state, so sorting, paging, and loading are
yours. A head can show a sort control and report which way it points, and a column of
numbers can line up on the right. For editing a value inside a cell, Cell
fields drops into any TableCell, and
Pagination is driven by the host in the same way
sorting is.
Owner | Open | ||
|---|---|---|---|
| Gesso UI kit | Joe | 8 | 24 |
| Washi database GUI | Joe | 13 | 7 |
| Subscription audit | WASH-E | 2 | 112 |
| Admin table migration | Marco | 5 | 0 |
Usage
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
<Table>
<TableHeader>
<TableRow>
<TableHead sortable sortDirection="asc" onSort={sortByName}>
Name
</TableHead>
<TableHead>Owner</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Gesso UI kit</TableCell>
<TableCell>Joe</TableCell>
</TableRow>
</TableBody>
</Table>Table wraps the <table> in a div with overflow-auto, so a table wider than its
container scrolls sideways instead of pushing the page. Heads are 48px tall and cells have
16px of padding, the table sets its text at 14px in font-sans, and a row shows
bg-fill on hover, and rows are ruled with the strong border. Set data-state="selected" on a
TableRow to draw it in bg-selected, the brand wash a selected list or nav row uses.
Sorting
A column is sortable when its head gets sortable. The head then renders a real button
over its whole width, in the tab order and pressed by Enter and Space, with a neutral caret
at the right edge, so a column that can sort looks different from one that cannot before
anyone clicks. sortDirection is the current direction, "asc" or "desc", and you omit
it while the column is not the active sort. A head given a sortDirection shows the
control even without sortable.
The button is named Sort by {label}, taken from the head's text when that is a plain
string and from sortLabel otherwise. With neither, the name is just Sort. Keyboard focus
draws one inset ring on the <th>, at the cell boundary, rather than a second ring on the
button inside it.
<TableHead
sortable
sortDirection={sort?.key === 'name' ? sort.direction : undefined}
onSort={() => toggle('name')}
>
Project
</TableHead>The comparison is yours. The head reports which way the arrow points and nothing else, so sorting on the server, with a cursor, or with TanStack Table's own sort state all drive it the same way. Click Project or Effort in the table above to sort it.
Alignment
TableHead and TableCell take align, one of start, center, or end. Set it on the
head and on every cell in the column, since the table has no column object to carry it.
Numbers read best with end and tabular-nums. A sortable head aligned to the end puts its
caret before the label, so the label still lines up with the values below it.
<TableHead sortable align="end" onSort={() => toggle('sent')}>
Sent
</TableHead>
<TableCell align="end" className="tabular-nums">
{row.sent}
</TableCell>Use align rather than text-right on a head. Its label sits in a flex row, so a text
class alone does not move it. start and end map to the physical left and right, so they
do not flip in a right-to-left layout.
Grouped rows
For a total with the parts behind it, such as an outcome and its statuses, render the parts as rows under the group and let them read as one block. There is no group variant, only four classes:
- The group row and every part but the last drop their rule with
border-b-0. - The group's cells hand most of their bottom padding to the block with
pb-1.5. - Part cells take
pt-1.5 pb-1.5, and the last part's takepb-4, so the block ends on the table's usual spacing. - The part's first cell indents with
pl-8and its text goes muted, so the group label leads.
Outcome | Recipients | Share |
|---|---|---|
| Delivered | 1,148 | 95.3% |
| Bounced | 41 | 3.4% |
| Hard bounce | 29 | 2.4% |
| Soft bounce | 12 | 1.0% |
| Not sent | 15 | 1.2% |
| Suppressed | 11 | 0.9% |
| Invalid address | 4 | 0.3% |
<TableRow className="border-b-0">
<TableCell className="pb-1.5 font-medium">Bounced</TableCell>
<TableCell align="end" className="pb-1.5 font-medium tabular-nums">41</TableCell>
</TableRow>
<TableRow className="border-b-0">
<TableCell className="pl-8 pt-1.5 pb-1.5 text-fg-muted">Hard bounce</TableCell>
<TableCell align="end" className="pt-1.5 pb-1.5 text-fg-muted tabular-nums">29</TableCell>
</TableRow>
<TableRow>
<TableCell className="pl-8 pt-1.5 pb-4 text-fg-muted">Soft bounce</TableCell>
<TableCell align="end" className="pt-1.5 pb-4 text-fg-muted tabular-nums">12</TableCell>
</TableRow>A group with no parts stays an ordinary row. Name each part so it makes sense on its own, since a screen reader reads the rows in order without the indent.
Caption and footer
TableCaption names the table and sits below it, in text-fg-muted at 14px. TableFooter
is a <tfoot> on bg-surface-sunken in medium weight, with a rule above it, for a total or
a summary row.
Project | Open |
|---|---|
| Gesso UI kit | 24 |
| Washi database GUI | 7 |
| Subscription audit | 112 |
| Admin table migration | 0 |
| Total | 143 |
<Table>
<TableCaption>Open issues by project</TableCaption>
{/* header and body */}
<TableFooter>
<TableRow>
<TableCell>Total</TableCell>
<TableCell align="end" className="tabular-nums">143</TableCell>
</TableRow>
</TableFooter>
</Table>Props
Every part takes the props of the element it renders. Table, TableHead, and TableCell
take these as well.
| Prop | Type | Default | What it does |
|---|---|---|---|
scrollLabel | string | The table's aria-label, or "Scrollable table" | On Table. Names the scroll region a wide table becomes, so a keyboard user can focus and scroll it. |
sortable | boolean | false | On TableHead. Renders the sort button and a neutral caret, and sets aria-sort. |
sortDirection | "asc" | "desc" | On TableHead. The current direction, which swaps the caret for an ascending or descending glyph. Omit it while the column is not the active sort. | |
onSort | () => void | On TableHead. Called when the sort button is pressed by click, Enter, or Space. | |
sortLabel | string | The head's text, when it is a string | On TableHead. The name in Sort by {sortLabel}, for a head whose children are not a plain string. |
align | "start" | "center" | "end" | "start" | On TableHead and TableCell. Aligns the text and, on a sortable head, the button. |
TableAlign is exported for typing a column definition.
Accessibility
- Table renders a native
<table>with<thead>,<tbody>,<tfoot>,<tr>,<th>,<td>, and<caption>, so assistive tech announces the row and column of each cell and reads a caption as the table's name. TableHeadrenders a<th scope="col">, and ascopeyou pass replaces it.TableCellalways renders a<td>, so a row head needs your own<th scope="row">.- A sortable head sets
aria-sorton the<th>, tononewhile it is unsorted and toascendingordescendingwhile it is the active sort. A head that cannot sort sets none. - The sort control is a native
<button type="button">namedSort by {label}, so Enter and Space press it. A change of sort is announced through a polite live region, like "Sorted by Name, ascending", and the sort the table loads with is not. - Focus on a sort button draws a 2px inset ring on the head, in the ring color.
- On a touch screen the sort button is at least 44px tall.
- When the table is wider or taller than its container, the scrolling wrapper becomes a
region named by
scrollLabeland takes focus, with a 2px inset ring, so the arrow keys scroll it. A table that fits adds no tab stop. - Row selection is a style only.
data-state="selected"draws the look, and exposing the selection (aria-selectedin a grid, or a checked checkbox in the row) is the host's job. - Nothing is sticky, so a long table scrolls its head out of view.
- The caret's color change stops under reduced motion.
When to use something else
- Cell fields when cells need to be edited.
It renders and edits a typed value inside a
TableCellfrom this page. - Record list for records grouped into buckets such as status, one line each, with the keyboard moving between them and Enter opening one. It has no columns to sort.
- Description list for the facts about one thing, as labels and values, rather than many things in columns.
- A plain list, such as Item rows, for a short run of rows that have no columns to compare.
- A data grid library when the table needs virtualized rows, resizable, reorderable, or pinned columns, row selection or grouping state, or arrow-key movement between cells. Table is markup and takes whatever such a library computes, and TanStack Table's headless state can drive it, but Table adds none of that itself.
Install
shadcn add @gesso/table