Pho Design System

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 LyekPayments AgentOpenJul 24, 2026$5.00
Mira OseiPayments AgentPaidJul 23, 2026$5.00
Tomas ReyesPayments AgentOpenJul 22, 2026$4.00
Priya NandPayments AgentExpiredJul 21, 2026$20.00
Jonah WeissPayments AgentPaidJul 20, 2026$20.00
Lena KovaPayments AgentOpenJul 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 NandPayments AgentExpiredJul 21, 2026$20.00
Jonah WeissPayments AgentPaidJul 20, 2026$20.00
Lena KovaPayments AgentOpenJul 19, 2026$12.50
Andy LyekPayments AgentOpenJul 24, 2026$5.00
Mira OseiPayments AgentPaidJul 23, 2026$5.00
Tomas ReyesPayments AgentOpenJul 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}
/>;

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 LyekPayments AgentOpenJul 24, 2026$5.00
Mira OseiPayments AgentPaidJul 23, 2026$5.00
Tomas ReyesPayments AgentOpenJul 22, 2026$4.00
Priya NandPayments AgentExpiredJul 21, 2026$20.00
Jonah WeissPayments AgentPaidJul 20, 2026$20.00
Lena KovaPayments AgentOpenJul 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 LyekPayments AgentOpenJul 24, 2026$5.00
Mira OseiPayments AgentPaidJul 23, 2026$5.00
Tomas ReyesPayments AgentOpenJul 22, 2026$4.00
Priya NandPayments AgentExpiredJul 21, 2026$20.00
Jonah WeissPayments AgentPaidJul 20, 2026$20.00
Lena KovaPayments AgentOpenJul 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 LyekPayments AgentOpenJul 24, 2026$5.00
Mira OseiPayments AgentPaidJul 23, 2026$5.00
Tomas ReyesPayments AgentOpenJul 22, 2026$4.00
Jonah WeissPayments AgentPaidJul 20, 2026$20.00
Lena KovaPayments AgentOpenJul 19, 2026$12.50
Expired
Priya NandPayments AgentExpiredJul 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 LyekPayments AgentOpenJul 24, 2026$5.00
Mira OseiPayments AgentPaidJul 23, 2026$5.00
Tomas ReyesPayments AgentOpenJul 22, 2026$4.00
Priya NandPayments AgentExpiredJul 21, 2026$20.00
Jonah WeissPayments AgentPaidJul 20, 2026$20.00
Lena KovaPayments AgentOpenJul 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 LyekPayments AgentOpenJul 24, 2026$5.00
Mira OseiPayments AgentPaidJul 23, 2026$5.00
Tomas ReyesPayments AgentOpenJul 22, 2026$4.00
Priya NandPayments AgentExpiredJul 21, 2026$20.00
Jonah WeissPayments AgentPaidJul 20, 2026$20.00
Lena KovaPayments AgentOpenJul 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