Components · Tables and data viz

Table

One table for both Flatpay's table use cases — read-only ledgers and reports, and object lists with selection, drag-and-drop, and bulk actions. Pass columns and rows; opt into the rest.

Documentedby Derek Fidler
End-of-day reports
IDFees
2033,42 €
1932,26 €
1890,00 €
1731,52 €
1624,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.

End-of-day reports
IDFees
2033,42 €
1932,26 €
1890,00 €
1731,52 €
1624,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.

Products
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-card and 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.

8 Feb 2024

5,981

Transactions

32.198,00 €

Total collected

31.872,30 €

Net collected

725,40 €

Total fees

31.146,90 €

Net payout

Daily payouts
DateNet 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.

Daily transactions
DateAmount
85,60 €
90,90 €
96,20 €
101,50 €
106,80 €
112,10 €
Wednesday 15 January 2025593,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.

Progressive reduction
360 px
Below sm (640 px): only the first and last columns survive — the canonical mobile 2-column layout. A chevron-right slots into each row to keep the tap affordance readable.

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.

Currency + date formatting
ReferenceAmount
RFB-2026-00011.234,56 kr.
RFB-2026-00028.456,00 kr.
RFB-2026-0003-250,50 kr.
RFB-2026-00041.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

Products
ProductPrice
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.

Density: compact
IDNet sales
201.114,00 €
191.075,25 €
183.000,00 €
density="compact"
Density: default
IDNet sales
201.114,00 €
191.075,25 €
183.000,00 €
density="default"
Density: spacious
IDNet sales
201.114,00 €
191.075,25 €
183.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 with aria-sort on the <th>. Idle headers carry aria-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 expose aria-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

PropTypeDefaultDescription
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) => stringStable id resolver. Used as React key, selection identity, and DnD identity.
captionstringVisually-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) => voidFires when a sortable header is clicked.
selection{ selectedIds, onSelectionChange }Adds a leading checkbox column. Selection is fully controlled.
bulkActionsTableBulkAction[]Toolbar actions shown above the table when at least one row is selected.
rowActionsTableRowAction<T>[]Per-row trailing kebab menu actions.
draggablebooleanfalseEnables HTML5 drag-to-reorder with a leading drag handle column.
onReorder(rows: T[]) => voidFires with the next ordered array after a drop.
onRowClick(row: T) => voidClick handler for the row body — skips clicks coming from interactive cells (checkbox / drag / row-action menu).
density"compact" | "default" | "spacious""default"Row padding step.
emptyStateReactNodeRendered in place of the rows when rows is empty.
filtersReactNodeSlot above the table — typically a <Filters> row with Search + a date range + optional filter chips. 16 px gap between this slot and the table.
metricsReactNodeSlot above the table — typically a <MetricsGroup> of summary tiles. Sits below `filters` when both are present, with a 16 px gap to the table.
pagenumberCurrent page (1-indexed). Required to enable internal pagination — without it, every row renders.
onPageChange(next: number) => voidCalled when the user navigates. Pair with `page` to enable pagination.
pageSizenumber100Page 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.mobilebooleanForce 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.noWrapbooleanForce `whitespace-nowrap` + ellipsis truncation on the cell. Defaults to `true` for right-aligned and tabular columns so currencies and dates never wrap mid-symbol.