Data Table
Records in columns: sortable headers, figures that line up, rows that open a
page. TanStack Table (v9) does the thinking —
column definitions, sorting, row identity — and renders nothing; every pixel
is Pho’s. The surface is BasicPage.Card’s, so a table sits in a page’s
Stack as any card does.
Reach for it when a list needs column semantics: sorting, aligned figures,
several fields side by side. A list where each row is one label and a chevron
stays BasicPage.RowLink.
Import
import {
DataTable,
createColumns,
} from "@photon-ai/pho-ui/components/data-table";
Inside a page it is also BasicPage.Table, and the section re-exports
createColumns — one import for the whole page file. A page carrying a
table runs wide on its own.
Columns
createColumns<T>() is TanStack’s column helper bound to the table’s feature
set: accessor for a field (typed), display for a column with no field,
columns to collect them with their types intact. A header is a string or
a render function; a cell renders the value — a formatted figure, a
Badge. meta.align: "end" aligns a column to the end with tabular
numerals, for money and counts.
| Andy Lyek | Payments Agent | Open | Jul 24, 2026 | $5.00 |
| Mira Osei | Payments Agent | Paid | Jul 23, 2026 | $5.00 |
| Tomas Reyes | Payments Agent | Open | Jul 22, 2026 | $4.00 |
| Priya Nand | Payments Agent | Expired | Jul 21, 2026 | $20.00 |
| Jonah Weiss | Payments Agent | Paid | Jul 20, 2026 | $20.00 |
| Lena Kova | Payments Agent | Open | Jul 19, 2026 | $12.50 |
const col = createColumns<Request>();
const columns = col.columns([
col.accessor("to", { header: "To" }),
col.accessor("status", {
header: "Status",
cell: ({ getValue }) => <Badge size="sm">{getValue()}</Badge>,
}),
col.accessor("amount", {
header: "Amount",
meta: { align: "end" },
cell: ({ getValue }) => money.format(getValue()),
}),
]);
<DataTable columns={columns} data={requests} />;
Headers are description ink at body size — never small caps, never bold. Header cells stay on one line; a wide table scrolls sideways inside its own frame, the page never does.
Sorting
Every accessor column sorts on its header (enableSorting: false on a column
turns it off there); the th carries aria-sort. The table owns the sort by
default — seed it with defaultSort. To sort on the server, own it: pass
sort with onSortChange and send the state along with the query.
sortFn: "datetime" tells a date column to sort as dates; TanStack infers the
rest from the data.
| Priya Nand | Payments Agent | Expired | Jul 21, 2026 | $20.00 |
| Jonah Weiss | Payments Agent | Paid | Jul 20, 2026 | $20.00 |
| Lena Kova | Payments Agent | Open | Jul 19, 2026 | $12.50 |
| Andy Lyek | Payments Agent | Open | Jul 24, 2026 | $5.00 |
| Mira Osei | Payments Agent | Paid | Jul 23, 2026 | $5.00 |
| Tomas Reyes | Payments Agent | Open | Jul 22, 2026 | $4.00 |
Sorted by amount descending.
const [sort, setSort] = useState<SortingState>([{ id: "amount", desc: true }]);
<DataTable
columns={columns}
data={requests}
sort={sort}
onSortChange={setSort}
/>;
Search
One box searches every column: type, and the rows narrow — sorting stays.
search turns it on; a string is the placeholder it shows. A column opts
out with enableGlobalFilter: false (an id column, say). Matching is a
plain case-insensitive contains, on the client; with no matches the body
says so. When the list doesn’t all come down, keep a box of your own
outside the table and ask the server.
Inside a page the search has two faces. Beside a section title,
BasicPage.Search is the boxed one, and it adopts the table’s search —
the table then renders none of its own; where the header is wide enough
it stands open, where it isn’t a round button opens it on click, a
linear glide with focus inside. Without a title, the table wears its
own: a ghost search line in the band at the top of the frame, quiet ink
on the cells’ text line, with toolbar filters after it (ghost
controls — a Select on variant="inline", a ghost Button). The
ghost face is an option too — <BasicPage.Search variant="ghost" />
anywhere, first in the toolbar to put it before or after the filters
as you like; the table’s own then yields.
| Andy Lyek | Payments Agent | Open | Jul 24, 2026 | $5.00 |
| Mira Osei | Payments Agent | Paid | Jul 23, 2026 | $5.00 |
| Tomas Reyes | Payments Agent | Open | Jul 22, 2026 | $4.00 |
| Priya Nand | Payments Agent | Expired | Jul 21, 2026 | $20.00 |
| Jonah Weiss | Payments Agent | Paid | Jul 20, 2026 | $20.00 |
| Lena Kova | Payments Agent | Open | Jul 19, 2026 | $12.50 |
<DataTable columns={columns} data={requests} search="Search requests" />
Selection
Hand the table onSelectedChange (or selected, to own the state) and
rows grow a leading checkbox column. The header checkbox works the
visible set — everything the search still shows — so select-all after a
search means what it says; rows picked earlier and then hidden stay
picked. Chosen rows keep the wash; ids come from getRowId. Bulk
actions are the page’s to render — count the ids, offer the action.
| Andy Lyek | Payments Agent | Open | Jul 24, 2026 | $5.00 | |
| Mira Osei | Payments Agent | Paid | Jul 23, 2026 | $5.00 | |
| Tomas Reyes | Payments Agent | Open | Jul 22, 2026 | $4.00 | |
| Priya Nand | Payments Agent | Expired | Jul 21, 2026 | $20.00 | |
| Jonah Weiss | Payments Agent | Paid | Jul 20, 2026 | $20.00 | |
| Lena Kova | Payments Agent | Open | Jul 19, 2026 | $12.50 |
Nothing chosen.
const [chosen, setChosen] = useState<string[]>([]);
<DataTable
columns={columns}
data={requests}
selected={chosen}
onSelectedChange={setChosen}
/>;
Groups
group files rows under quiet labels: return a label and the row renders
in that group, null and it stays in the main body. Groups follow the
main body in the order their labels first appear in the data, sorting
orders rows within their group, and a group only stands while it has
rows — search away its last row and the label leaves with it. Pending
invitations under a members roster, archived records under live ones.
| Andy Lyek | Payments Agent | Open | Jul 24, 2026 | $5.00 |
| Mira Osei | Payments Agent | Paid | Jul 23, 2026 | $5.00 |
| Tomas Reyes | Payments Agent | Open | Jul 22, 2026 | $4.00 |
| Jonah Weiss | Payments Agent | Paid | Jul 20, 2026 | $20.00 |
| Lena Kova | Payments Agent | Open | Jul 19, 2026 | $12.50 |
| Expired | ||||
| Priya Nand | Payments Agent | Expired | Jul 21, 2026 | $20.00 |
<DataTable
columns={columns}
data={rows}
search
group={(row) => (row.pending ? "Invitations" : null)}
/>
Rows that open a page
rowHref makes each row one link into a subpage, rendered through the host’s
LinkProvider like every Pho link. current is the id of the row whose page
is open — it keeps the wash, the same cue BasicPage.RowLink gives under
aria-current. Beside a BasicPage.Aside, that is the row showing on the
right. Row identity is getRowId — the row’s id field by default.
| Andy Lyek | Payments Agent | Open | Jul 24, 2026 | $5.00 |
| Mira Osei | Payments Agent | Paid | Jul 23, 2026 | $5.00 |
| Tomas Reyes | Payments Agent | Open | Jul 22, 2026 | $4.00 |
| Priya Nand | Payments Agent | Expired | Jul 21, 2026 | $20.00 |
| Jonah Weiss | Payments Agent | Paid | Jul 20, 2026 | $20.00 |
| Lena Kova | Payments Agent | Open | Jul 19, 2026 | $12.50 |
Open: prq_3f1a. Beside a BasicPage.Aside, the row's page would be showing on the right.
const outlet = useOutlet();
const { requestId } = useParams();
<BasicPage.Root>
<BasicPage.Title>Outgoing requests</BasicPage.Title>
<BasicPage.Stack>
<div>
<BasicPage.Table
columns={columns}
data={requests}
rowHref={(row) => `/requests/${row.id}`}
current={requestId}
/>
</div>
</BasicPage.Stack>
<BasicPage.Aside placeholder="Open a request to see it here.">
{outlet}
</BasicPage.Aside>
</BasicPage.Root>;
Size
size sets the body row height and the type that comes with it. base, the
default, is 48px rows at 14px: one line of body type. sm is 40px at 13px,
the header’s own height, for a dense list scanned more than read. lg is
56px at 16px, for a cell that stacks two lines (a number and a caption) and
would crowd the default. The header row keeps its 40px on every size and
follows the type, so the whole table shifts register together.
| Andy Lyek | Payments Agent | Open | Jul 24, 2026 | $5.00 |
| Mira Osei | Payments Agent | Paid | Jul 23, 2026 | $5.00 |
| Tomas Reyes | Payments Agent | Open | Jul 22, 2026 | $4.00 |
| Priya Nand | Payments Agent | Expired | Jul 21, 2026 | $20.00 |
| Jonah Weiss | Payments Agent | Paid | Jul 20, 2026 | $20.00 |
| Lena Kova | Payments Agent | Open | Jul 19, 2026 | $12.50 |
<DataTable size="sm" columns={columns} data={requests} />
<DataTable size="lg" columns={columns} data={messages} layout="fixed" />
Loading and empty
loading lays bones in the rows’ place — true for five, or a count that
matches what is coming — and marks the table busy. With no rows and nothing
loading, the body says empty (a plain line; give it the page’s own words).
animateHeight makes the frame follow the rows on the page spring — bones
becoming rows, a search narrowing, a filter landing — instead of jumping;
reserve it for tables whose rows change while mounted, the same contract as
BasicPage.Card’s.
<DataTable
columns={columns}
data={requests ?? []}
loading={isPending}
empty="No requests yet."
animateHeight
/>
Guidelines
- Column semantics, or
RowLink. Sorting, aligned figures, several fields per row: a table. One label and a chevron per row:RowLink. - Figures to the end. Money, counts, dates-as-numbers align to the end
with tabular numerals (
meta.align: "end"); the header follows. - The row is the link. Put nothing else clickable in a row that navigates; actions belong on the row’s page, one level down.
- Wide tables scroll, pages don’t. Keep the page’s column; let the table
scroll sideways inside its frame. On a wide page (
Root width="wide") it has room to breathe.