Data table
Column-driven table with page and cursor pagination, sortable headers, a rows-per-page picker, loading skeleton, and empty state.
Preview
Interactive examples use the same @flux/ui build as production. Toggle theme in the header for light and dark. Motion tokens and layout patterns live under Motion and Patterns in the sidebar.
Live preview
Showing 1–3 of 8 results
Installation
Add the package to your app (full Next.js + Tailwind steps on Installation):
npm install @payglocal_ui/flux-uiUsage
Import
import { DataTable, Button, type Column } from "@payglocal_ui/flux-ui";Example
const columns: Column<Row>[] = [
{ key: "name", header: "Name", render: (r) => r.name },
];
// Basic usage — client-side paging at 10 rows a page.
<DataTable rowKey={(r) => r.id} columns={columns} data={rows} />
// ── Pagination ────────────────────────────────────────────────────────
// One `pagination` prop covers every mode, so which one an endpoint needs
// is the only thing that differs between two tables.
// The response carries a row TOTAL: a full numbered strip with ellipses,
// and "Showing 1–15 of 141 results".
<DataTable
rowKey={(r) => r.id}
columns={columns}
data={page.rows}
pagination={{
mode: "page",
page,
pageSize: 15,
total: page.totalCount,
onPageChange: setPage,
}}
/>
// The response carries NO total (a nextCursor / exclusiveStartKey API).
// "Showing 1–15" with no total and no page count, and the strip numbers
// only the pages a cursor can actually step to: the previous page, this
// one, and — only when hasNext says so — the next. On page 2 that reads
// 1 · 2 · 3, so the next page is visible as a number rather than hiding
// behind an arrow.
<DataTable
rowKey={(r) => r.id}
columns={columns}
data={page.rows}
pagination={{
mode: "cursor",
page,
pageSize: 15,
hasNext: !!page.nextCursor,
onNext: () => goForward(page.nextCursor),
onPrev: () => goBack(),
}}
/>
// Rows per page — one prop pair, drawn at the far left of the footer.
// No hand-rolled footer, and no second pager in the same app.
<DataTable
rowKey={(r) => r.id}
columns={columns}
data={rows}
pagination={{
mode: "page",
page,
pageSize,
total,
onPageChange: setPage,
pageSizeOptions: [15, 25, 50, 100],
onPageSizeChange: (n) => { setPageSize(n); setPage(1); },
}}
/>
// No footer at all.
<DataTable rowKey={(r) => r.id} columns={columns} data={rows} pagination={{ mode: "none" }} />
// ── Sorting ───────────────────────────────────────────────────────────
// Modelled on antd's Table: a column opts in with `sorter`, and state is
// reported as { columnKey, order } using antd's "ascend" / "descend".
// Client and server sorting are told apart by the COLUMN, so one grid can
// mix them.
const columns: Column<Row>[] = [
// The table sorts these rows itself, before paging.
{ key: "name", header: "Name", sorter: (a, b) => a.name.localeCompare(b.name), render: (r) => r.name },
// `true` = the caller orders the rows (a server query). The table only
// reports the click. sortDirections starts the cycle at descending,
// which is the first click money and dates almost always want.
{ key: "amount", header: "Amount", sorter: true, sortDirections: ["descend", "ascend"], render: (r) => r.amount },
];
// Uncontrolled: the table remembers. All a client-sorted grid needs.
<DataTable rowKey={(r) => r.id} columns={columns} data={rows} />
// Controlled: feed the state into the request.
<DataTable
rowKey={(r) => r.id}
columns={columns}
data={rows}
sorting={{ value: sort, onChange: setSort }}
/>
// Content layout — columns shrink to their intrinsic width; leftover
// space stays empty to the right instead of stretching the columns.
<DataTable
rowKey={(r) => r.id}
columns={columns}
data={rows}
tableLayout="content"
/>
// Row action — a render-prop button revealed on row hover, pinned to
// the right edge of the viewport. Prefer this over rowCta when you
// need per-row navigation or a custom button style.
<DataTable
rowKey={(r) => r.id}
columns={columns}
data={rows}
rowAction={(row) => (
<Button variant="outline" size="sm" onClick={() => openDetails(row)}>
View details
</Button>
)}
/>
// Row click — the whole row is the target, including cell padding and
// the gaps between columns. The row gets cursor-pointer and keyboard
// access (focusable, Enter / Space). Clicks that start inside a button,
// link, form control or anything marked data-row-click-ignore do not
// fire it, so per-row buttons and copy controls keep working on their
// own without stopping propagation.
<DataTable
rowKey={(r) => r.id}
columns={columns}
data={rows}
onRowClick={(row) => openDetails(row)}
/>