| ID | Fees |
|---|---|
| 20 | 33,42 € |
| 19 | 32,26 € |
| 18 | 90,00 € |
| 17 | 31,52 € |
| 16 | 24,66 € |
End-of-day reports
Overview
The Table lives in @flatpay-dk/ui as a single component. It covers the whole spectrum of tabular UI in the product — from a quiet five-column settlement ledger to a hundred-row product index with selection, drag handles, and bulk actions. Every feature beyond “render columns and rows” is opt-in.
Two jobs, one component
The Figma library has historically split tables into Data table (read-only financial) and Index table (manipulatable objects). They share the same vocabulary — header cells, body cells, dividers, type stack — and differ only in which features are turned on. Maintaining two parallel components made the visual rhythm drift between sections of the product. The system consolidates them into one.
| ID | Fees |
|---|---|
| 20 | 33,42 € |
| 19 | 32,26 € |
| 18 | 90,00 € |
| 17 | 31,52 € |
| 16 | 24,66 € |
Read-only · End-of-day reports
Just columns and rows, with sortableon the date and amount columns. No selection, no drag handles, no row actions — they aren’t passed in.
| Stock | |||||
|---|---|---|---|---|---|
| 22 | |||||
| 48 | |||||
| 16 | |||||
| 80 | |||||
| 36 |
Object list · Products with selection, drag, and row actions
Same component, opt-in features: selection shows the checkbox column, bulkActions renders the toolbar above the table when rows are selected, draggable adds drag handles, and rowActions trails a kebab menu on each row.
Why one component
Two tables means two visual languages. A merchant scanning the product index in the morning and the settlement ledger in the afternoon should feel the same hand. Restraint is the brand — adding a feature flag is cheaper than maintaining a parallel component.
Anatomy
- Container.8 px radius, 1 px border on every side. The outer card carries the table’s identity; rows divide with 1 px borders inside it.
- Header row.Inter Tight Semibold 11 px, tracked-out (0.06 em) ALL-CAPS — the eyebrow utility’s shape. Sits on a faint
bg-foreground/[0.02]tint to separate it from body rows. - Body rows. Inter Tight 14 px primary. Even and odd rows alternate between
bg-cardand a 0.015-foreground stripe — barely visible alone, but enough to keep dense ledgers readable. - Numeric cells. Right-aligned columns auto-apply
tabular-nums+ slashed zero. Currency follows the currency typesetting rules — Inter Tight in product UI, suffix euro symbol. - Optional columns.Selection (checkbox), drag handle, and row-action menu render as auto-sized leading or trailing columns when their props are present. Each of those columns blocks the row’s click handler so the bulk affordances don’t fight with
onRowClick.
Filters · Metrics · Pagination
Every product table comes with a Filters row above and a Pagination row below. Both ship as slot props (filters, metrics, pagination via page / onPageChange) so the surrounding chrome stays consistent — 16 px gap between every section, no outer border around the table itself, header tinted with bg-accent-overlay-light-subtler. Search and Time period default into the Filters row; column customizer + extra filter chips are optional. Pagination is always rendered when more than one page exists, capped at 100 rows per page by default — labelled variant on desktop, compact variant on mobile.
5,981
Transactions
32.198,00 €
Total collected
31.872,30 €
Net collected
725,40 €
Total fees
31.146,90 €
Net payout
| Date | Net payout |
|---|---|
| 3.714,75 € | |
| 3.811,34 € | |
| 3.913,44 € | |
| 4.054,03 € | |
| 4.142,62 € | |
| 4.269,72 € | |
| 4.393,31 € | |
| 4.498,90 € | |
| 4.601,00 € | |
| 4.732,59 € |
Subtotals
Pass subtotals to group rows (typically by calendar day) and sum a value for each group. The subtotal row sits directly beneath the last row of its group on the page where the group ends — and crucially, the totals are computed across the full dataset, so a group split across pages still shows the correct sum when the user scrolls to the page where it closes out. Use it for daily ledgers, periodic settlements, anywhere a group boundary is a meaningful "this is the total so far" moment.
| Date | Amount | ||||
|---|---|---|---|---|---|
| 85,60 € | |||||
| 90,90 € | |||||
| 96,20 € | |||||
| 101,50 € | |||||
| 106,80 € | |||||
| 112,10 € | |||||
| Wednesday 15 January 2025 | 593,10 € | ||||
Progressive reduction
The table preserves as many columns as fit in the viewport, driven by the standard sm / md / lg / xl / 2xl Tailwind breakpoints. Below sm(640 px) only two columns remain — the canonical “mobile” layout. The first column and the last columnstay visible at every width — typically the row’s identifier and its primary value. Middle columns drop right-to-left as the viewport narrows: the leftmost middle column hides last, the rightmost middle column hides first. Override and force a middle column to always be visible by setting mobile: true on it (rare). When onRowClickis set, a chevron-right glyph appears on the right edge whenever the table is reduced — so the row’s “tap to open” affordance still reads when the trailing data column is gone. The chevron disappears once every column fits. If you need slot-relative collapse (e.g. a table inside a narrow sidebar at desktop width), wrap the table in your own container query.
Cell content
Currencies and dates have strict content rules. Format every currency through Intl.NumberFormat with an explicit locale + the currency option (see /content/currency). Format every date through Intl.DateTimeFormat and wrap it in a <time dateTime> element so screen readers + crawlers get the machine value. Reach for the Mediumformat (“5 May 2026”, d MMM yyyy) by default — every product surface, including dense table cells. Short numerical(“05/05/2026”) is reserved for accounting + reporting contexts only: receipts, printed reports, XLS exports. Logs and audit trails use ISO 8601, never short numerical (see /content/date-and-time). Right-aligned and tabular columns default to whitespace-nowrap + ellipsis truncation so a currency or a date never wraps mid-symbol or mid-day. Override noWrap explicitly on free-text columns that should wrap.
| Reference | Amount |
|---|---|
| RFB-2026-0001 | 1.234,56 kr. |
| RFB-2026-0002 | 8.456,00 kr. |
| RFB-2026-0003 | -250,50 kr. |
| RFB-2026-0004 | 1.899,99 kr. |
Sorting
Mark a column sortable: true and pass sort + onSortChange. The component renders a clickable header with an idle / ascending / descending chevron. The actual sort happens in your reducer — the component is fully controlled.
Always sort one column at a time
Multi-column sort feels powerful in design system documentation and confusing in actual product UI. The component is single-sort by design — click a different header to switch.
Selection + bulk actions
Pass selection with selectedIds and onSelectionChange to add a leading checkbox column. The header checkbox toggles all rows; clicking individual rows toggles them. When at least one row is selected, the bulk actions toolbar slides into view above the table.
Floating · Table bulk actions
| Product | Price | |
|---|---|---|
| Premium POS terminal | € 1,299.00 | |
| Pro POS tablet | € 899.00 | |
| A920 Pro mobile terminal | € 549.00 | |
| Z11 PIN pad | € 199.00 | |
| Receipt printer · TM-T20 | € 129.00 | |
| Cash drawer · CD-501 | € 89.00 |
3 products selected
- Indeterminate state. When some but not all rows are selected, the header checkbox shows a horizontal bar instead of a tick — the standard tri-state pattern.
- Destructive actions go last. Pass
variant: "danger"on the action and the toolbar tints it rose. Convention is to place destructive options at the right of the toolbar so the eye lands on the safer choices first. - Cap the toolbar. Three to four bulk actions is the comfortable maximum. If the surface needs more, surface a kebab on the right with the rest behind it — the same pattern the row uses.
Drag and drop
Pass draggable and onReorder to add a drag handle in the leftmost content column. The component handles the HTML5 drag events itself; the consumer receives the next ordered array of rows in onReorder and stores the new order. A drop indicator (1 px outline) marks the target row during the drag.
Drag is for stable orderings
Reach for drag when the order itself is data the user owns — menu sequence, station priority, dining-table layout. Don’t use it for transient sorts like “newest first”; that’s a sort, not a reorder.
Row actions
A trailing kebab on every row, opening a small menu of actions — Edit, Reassign, Delete. Use it for actions that are common enough to be one click away, but not common enough to deserve a dedicated column. Three to five is the right ceiling; above that, the row should open a detail surface instead.
Density
Three steps — compact for ledgers where you want every row in view, default for everything else, spacious for settings tables where each row carries a description. Density changes only the row padding; type sizes stay the same.
| ID | Net sales |
|---|---|
| 20 | 1.114,00 € |
| 19 | 1.075,25 € |
| 18 | 3.000,00 € |
density="compact"| ID | Net sales |
|---|---|
| 20 | 1.114,00 € |
| 19 | 1.075,25 € |
| 18 | 3.000,00 € |
density="default"| ID | Net sales |
|---|---|
| 20 | 1.114,00 € |
| 19 | 1.075,25 € |
| 18 | 3.000,00 € |
density="spacious"Empty state
Pass emptyState as either a config object — { icon, title, description, action } — and the table renders the canonical layout (24 px icon inside a 96 px muted circle, headline, sub-line, optional primary action), or as a ReactNode if you want the surface fully bespoke. The action is optional and should be wired to the page’s primary action — “Add product”, “Make a sale or refund”. Read-only tables (settlement ledgers, end-of-day reports, audit logs) never carry an action; the empty state is informational only.
No products yet
Pull a category from the catalog to add products to this menu.
With primary action
No transactions
Make a sale or refund.
Read-only · no action
Accessibility
- Caption. Required. The component renders an SR-only
<caption>so screen readers know what the table is. - Sort headers. Sortable headers are real
<button>elements witharia-sorton the<th>. Idle headers carryaria-sort="none"so the user can hear which columns are sortable. - Selection. Each checkbox carries an
aria-label; the header checkbox announces its tri-state. Selected rows exposearia-selected. - Drag and drop.Native HTML5 DnD only — keyboard reordering isn’t built in. If your product needs keyboard reorder, pair the drag handle with a row-action “Move up” / “Move down” pair until a richer pattern lands.
Code
tsx
import { Table, type TableColumn } from "@flatpay-dk/ui";
// Read-only financial — minimal API
const columns: TableColumn<Report>[] = [
{ id: "id", header: "ID", cell: (r) => r.id, width: 60 },
{ id: "endDate", header: "End date", cell: (r) => r.endDate, sortable: true },
{ id: "netSales", header: "Net sales", cell: (r) => formatEur(r.netSales),
align: "right", sortable: true },
];
<Table
caption="End-of-day reports"
columns={columns}
rows={reports}
getRowId={(r) => r.id}
sort={sort}
onSortChange={setSort}
/>
// Object list — opt in to selection, bulk actions, drag, row actions
<Table
caption="Products"
columns={productColumns}
rows={products}
getRowId={(r) => r.id}
selection={{ selectedIds, onSelectionChange: setSelectedIds }}
bulkActions={[
{ label: "Reassign category", onActivate: (ids) => reassign(ids), icon: <TagIcon /> },
{ label: "Delete", variant: "danger", onActivate: (ids) => destroy(ids), icon: <TrashIcon /> },
]}
rowActions={[
{ label: "Edit", icon: <EditIcon />, onActivate: (row) => editProduct(row) },
{ label: "Delete", variant: "danger", icon: <TrashIcon />, onActivate: (row) => destroyOne(row.id) },
]}
draggable
onReorder={setProducts}
onRowClick={(row) => router.push(`/products/${row.id}`)}
/>Best practices
- Right-align numbers; left-align text. The component does this automatically when you set
align: "right", along with applying tabular numerals + slashed zero. Numeric columns shouldn’t need a custom font stack on top. - Use the column scale.A six-column table reads; a fifteen-column table doesn’t. If the surface needs more than ~7 columns, hide the long tail behind a row expand or a detail surface — don’t make the table horizontally scroll forever.
- Don’t recolour rows for status.A whole row tinted rose for “failed” reads as alarming wallpaper. Use a status pill in a column instead — colour earns its place by communicating state, not decorating.
- Pair onRowClick with a deliberate detail surface. If clicking a row opens nothing, drop the click handler. Quiet clickability without a destination is worse than no clickability at all.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| columns* | TableColumn<T>[] | — | Column definitions. Each carries id, header, cell renderer, and optional align / width / sortable / tabular. |
| rows* | readonly T[] | — | The current row data. |
| getRowId* | (row: T) => string | — | Stable id resolver. Used as React key, selection identity, and DnD identity. |
| caption | string | — | Visually-hidden table caption. Required for accessibility on tables that aren't already named by surrounding chrome. |
| sort | { columnId, direction } | — | Current sort state. |
| onSortChange | (next: TableSortState) => void | — | Fires when a sortable header is clicked. |
| selection | { selectedIds, onSelectionChange } | — | Adds a leading checkbox column. Selection is fully controlled. |
| bulkActions | TableBulkAction[] | — | Toolbar actions shown above the table when at least one row is selected. |
| rowActions | TableRowAction<T>[] | — | Per-row trailing kebab menu actions. |
| draggable | boolean | false | Enables HTML5 drag-to-reorder with a leading drag handle column. |
| onReorder | (rows: T[]) => void | — | Fires with the next ordered array after a drop. |
| onRowClick | (row: T) => void | — | Click handler for the row body — skips clicks coming from interactive cells (checkbox / drag / row-action menu). |
| density | "compact" | "default" | "spacious" | "default" | Row padding step. |
| emptyState | ReactNode | — | Rendered in place of the rows when rows is empty. |
| filters | ReactNode | — | Slot above the table — typically a <Filters> row with Search + a date range + optional filter chips. 16 px gap between this slot and the table. |
| metrics | ReactNode | — | Slot above the table — typically a <MetricsGroup> of summary tiles. Sits below `filters` when both are present, with a 16 px gap to the table. |
| page | number | — | Current page (1-indexed). Required to enable internal pagination — without it, every row renders. |
| onPageChange | (next: number) => void | — | Called when the user navigates. Pair with `page` to enable pagination. |
| pageSize | number | 100 | Page size for internal pagination. The Pagination row renders below the table — labelled variant on desktop, compact on mobile. |
| subtotals | { groupBy: (row) => string; columnId: string; label?; render: ({ key, rows }) => ReactNode } | — | Group rows (typically by calendar day) and emit a subtotal tr at each group boundary. Totals compute across the FULL dataset, so a group split across pages still shows the correct sum on the page where it ends. |
| TableColumn.mobile | boolean | — | Force this column to always be visible. Most consumers don't need this — the table reduces progressively, keeping the first and last columns at every width and dropping middle columns right-to-left as the container narrows. Set this on a middle column you want pinned (a status column, say). |
| TableColumn.noWrap | boolean | — | Force `whitespace-nowrap` + ellipsis truncation on the cell. Defaults to `true` for right-aligned and tabular columns so currencies and dates never wrap mid-symbol. |