flyingsalmon

Table

A styled primitive for tabular data: plain table elements in seven parts. Horizontal rules only, an indicator-colored header rule, left-aligned column labels, and an interactive row whose single link stretches over the whole row and turns the rules above and below it the same color.

Seven parts

Table, TableHeader, TableBody, TableFooter, TableRow, TableHeadCell, and TableCell — each one a plain element with the matching tag. Table wraps the table in a full-width scrolling div and sends your className to the table itself. There is no caption part and no empty-state part: an empty table is a row with a colSpan cell you write yourself, because only you know the column count.

The sections own the rules. Every body row keeps a --border line, including the last, so the table terminates on a rule; the header row draws a 1px --table-header-border line, which is --indicator; the footer draws none. There are no vertical rules and no outer border — drop the table inside a Card if you want it boxed. Only an interactive row responds to hover, stepping its background to --accent at --motion-fast; a row you cannot click stays still, because a background step that leads nowhere reads as an affordance that is not there. The header and footer never respond to hover.

Light
StopNightsLodging
Kyoto3Ryokan Aoi
Kanazawa2Hotel Higashi
Tokyo4Shibuya Loft
Total93 stays
Dark
StopNightsLodging
Kyoto3Ryokan Aoi
Kanazawa2Hotel Higashi
Tokyo4Shibuya Loft
Total93 stays

Rows that lead somewhere

Pass interactive to a TableRow, then mark the cell holding the row’s link with rowLink — not necessarily the first cell. That link stretches its hit area over the whole row through a pseudo-element, so a click anywhere on the row follows it and Enter activates it natively. The row is not a link and holds no key handler of its own. You write the anchor yourself, so a router link works the same as an <a>.

Every other cell keeps its contents above the stretched link, so a second link or a button in the same row clicks, tabs, and shows its own focus ring exactly as it would anywhere else. Nothing to opt into. Mark two cells rowLink and the two overlays fight over the same area — that one is on you, and it is visible in your markup rather than inferred from the row.

Focus and press build on hover rather than replacing it. The row takes the same --accent background a pointer gives it, and the line above it and the line below it both turn --indicator, in the same weight and color as the header rule. The first body row borrows the header rule as its top line. The background is what tells you which row the two lines belong to, since the upper one is also the previous row’s lower one; focus is the louder state because a keyboard user has no pointer to say where they are.

Widths and alignment are yours

One size, no density prop, and no align prop — align is a real HTML attribute on a cell. Column labels always sit left, whatever the column holds. Body cells are yours: numerics take className="text-right" so digits line up by place value, which is the only way to compare two amounts by reading down the column. A label is a word, not a quantity, so nothing lines up against it and moving it right buys nothing. Column widths and a tighter row rhythm are className too.

A column that holds only controls takes an empty TableHeadCell. The button already names the action, so a label above it repeats what is written below and then sits far from it, since the controls are right-aligned to give the row a consistent end. Name an action column only when the label says something the buttons do not.

The wrapper is overflow-x-auto and the table is w-full, with no minimum width and no whitespace-nowrap. Cell text wraps by default, so horizontal scroll engages only when you set column widths or opt a column into nowrap. It is not automatic.

Light
StopNightsCost
Kyoto3¥48,000
Kanazawa2¥26,500
Total5¥74,500
Dark
StopNightsCost
Kyoto3¥48,000
Kanazawa2¥26,500
Total5¥74,500

Row headers

TableHeadCell defaults to scope="col" and exposes the columnheader role. Put one in a body row with scope={TableHeadCellScope.Row} and it exposes rowheader instead, so a screen reader announces each cell with the row it belongs to. Styling keys off the scope, not the position: a row header takes body geometry rather than the column label’s row height. Every cell in the table is --foreground; head cells separate from body cells by font-medium alone.

Light
StopArriveDepart
Kyoto12 Apr15 Apr
Kanazawa15 Apr17 Apr
Dark
StopArriveDepart
Kyoto12 Apr15 Apr
Kanazawa15 Apr17 Apr

Paged tables

A table longer than one screen takes a Pagination under it, right-aligned, where the eye lands after the last row. The table renders the rows the app hands it and the control reports the page the user asked for; slicing the data, the page size, and the range line are the app’s. The two components know nothing about each other, so the composition is a wrapper with a flex rule and nothing else.

Light
StopNightsLodging
Kyoto3Ryokan Aoi
Kanazawa2Hotel Higashi
Dark
StopNightsLodging
Kyoto3Ryokan Aoi
Kanazawa2Hotel Higashi

Table sorts nothing, selects nothing, and paginates nothing. Those belong to a data table built on top of this one.