Ledger & tables
The table is the product. It gets the same design investment as the hero card.
47. TransactionTable
The workhorse. Everything about it is a craft decision.
Column priority
P1 date · description · amount never dropped
P2 status · account
P3 category · merchant
P4 reference · memo · created-by · tags
Four presentations
| Width | Presentation |
|---|---|
| ≥1280 | Full table, sticky header, sticky total footer |
| 1024–1280 | P4 dropped into a per-row expand |
| 768–1024 | Sticky description (left) + sticky amount (right), metadata scrolls between |
| <768 | TransactionCard list — a genuine re-rank |
The 768–1024 pattern is the one nobody implements and the one that makes a wide table usable at laptop width: the label and the number stay pinned; the metadata scrolls between them, so the two things being correlated are always both on screen. Details — sticky cell backgrounds, IntersectionObserver-driven edge fades, inner borders, focus rings that don't clip — in responsive.
Craft
- Virtualized past 100 rows (
@tanstack/react-virtual), with correctaria-rowcount/aria-rowindexso a screen reader knows the real total. - Amounts right-aligned,
tnum, paren column reserved, hugging the card inset. - Row heights on the grid: 32 / 40 / 48 by density.
- Zebra striping: no.
--border-subtlerow dividers instead. Striping doubles the background changes on a scrolling surface and fights with hover and selection. - Selection is a checkbox column, always visible on touch, hover-or-focus-revealed on desktop.
Shift-click range select,Cmd-click toggle. - Sort is instant. No FLIP, no stagger. High frequency. Every column sorts on its
value — the amount column on minor units, the status column on workflow urgency rather
than the alphabet, so one click puts the exceptions on top. Uncontrolled by default;
pass
sort+onSortChangeand the host owns it, which is the only correct arrangement once the rows are a page of a larger set. - Columns drag. Widths are px, persisted per user under
columnStorageKey, withdescriptiontaking the slack. SeeColumnHeader. - Sticky header gains its shadow only once content scrolls beneath (sentinel +
IntersectionObserver). - Sticky total footer — a single row height plus a 1px top rule. This is the number people are actually looking for.
- Pending rows carry a 2px
--warningleft border and their pending status, at full opacity: a row dimmed to 0.6 drops its secondary text under AA (3.2:1). Confirmation removes the border.
Keyboard
Full grid model — arrows navigate cells, the grid is one tab stop. Table in accessibility.
Motion
Tier 0 almost entirely. Row hover is background-color at 100ms — no transform, no shadow, no lift. Insert and delete animate; sort, filter, and paginate do not.
48. TransactionRow
[checkbox] [date] [mark] [description / merchant] [category] [account] [status] [amount]
Description truncates; amount never does. Merchant name on a second line at caption/--text-secondary when it differs from the description. Row actions revealed on hover/focus, right-aligned, always present on touch.
49. TransactionCard
The mobile re-rank. 72px, 16px inset, full-bleed divider, amount right-aligned and never truncated, 32px mark, swipe-left actions with an overflow-menu equivalent because swipe is undiscoverable. Sticky date section headers.
50. TransactionDetail
A sheet on phone, a side panel on desktop. Everything: full amounts, FX detail if converted, attachments, the categorization rule that fired, the full audit trail with actor and timestamp. Inline editing for category and memo, with optimistic update and rollback on failure.
51. LedgerTable
Double-entry. Debit and credit as two separate columns, never one signed column — collapsing them destroys the reader's ability to verify, which is the entire purpose of double-entry. Column totals with the balance check displayed; a failing check is a prominent error state, not a silent discrepancy.
The shared grid model
Every table in this family — and in statements, schedules and the close — is driven by the same two primitives, because an accountant does not distinguish between them. They arrive from a spreadsheet and press the arrow keys.
useGridNavfor tables that are already client components (TransactionTable).GridKeyboardwraps the ones that are deliberately server-rendered, reading the DOM they produced rather than forcing a rewrite — a 400-row statement should not ship 400 rows of JavaScript to be rendered twice.- One tab stop per grid. Tab to the table, arrow within it. Arrows move,
Cmd+arrow jumps to the edge,PageUp/PageDownmove a screen,Home/Endthe row,Spaceselects,Enteropens,Escapecollapses. Shift+arrows select a block,Cmd+C copies it — as TSV and as HTML, so every paste target gets its best flavour.- The clipboard carries the value, not the presentation.
Moneystamps every figure withdata-raw, so a copied column lands in Excel as-1240.56and sums.($1,240.56)pastes as text, and a column of text does not add up. - The label column freezes. Any table wider than its container pins its row
header with
FROZEN_LABEL— scrolling a general ledger sideways and losing the account name is the exact motion this family exists to support. - Every column sorts and drags.
ColumnHeaderfor the tables that render their own rows,TableColumnsfor the ones that render on the server — the same split, and the same reason, asuseGridNavandGridKeyboard.
52. AgingTable
AR/AP by bucket: current / 1-30 / 31-60 / 61-90 / 90+. Row totals and column totals. The intersection cell (grand total) is proven both ways — rows and columns must agree, and the component asserts it.
Bucket severity uses a sequential lightness ramp, not a red gradient, so it survives greyscale and the signal palette.
53. ReconciliationView
Two panes: bank statement lines, book entries. Suggested matches with confidence, one-click accept, manual multi-select match (many-to-one and one-to-many both supported — real reconciliation is rarely 1:1).
The match animation is one of the four sanctioned moments: highlight → FLIP together → merge with a check. Spec. Bulk match runs the sequence once for the group, staggered at 40ms with a 240ms cap.
Running totals for both sides, with the remaining difference always visible. That difference is the number the user is trying to get to zero, so it never scrolls away.
54. CategoryPicker
Hierarchical, searchable, keyboard-first. Recents pinned. Inline "create a rule from this" — categorizing one transaction and offering to categorize the other 40 like it is the highest-leverage interaction in bookkeeping software. Rule preview shows the match count before applying.
55. FilterBar
URL-param-backed, so a filtered view is shareable and survives a refresh. Chips show resolved values — "1–30 Sep 2026", not "Last 30 days". Clear-all always present. Result count updates live. Filter state changes are instant, no animation.
56. BulkActionBar
Appears when rows are selected. Bottom-anchored on phone with safe-area padding, inline on desktop. States the count (42 selected), offers actions, and names the destruction: "Delete 42 transactions", never "Delete". Enters with a 240ms translate + fade — occasional, so it's licensed.
57. ColumnManager
Show/hide/reorder with drag. Persisted per user per table. Drag uses FLIP + spring.snap; measure once at drag start, never during. Keyboard reorder via Cmd+arrows.
171. TableColumns
Sortable, resizable columns for a table that renders on the server.
The sibling of GridKeyboard, and the same argument. Most ledger tables here are
deliberately server components, because a 400-row statement should not ship 400 rows of
JavaScript to be rendered twice. But a bookkeeper arrives from Excel, and in Excel every
column sorts and every column drags. Paying for that by rewriting nineteen tables into
client components is the worse trade.
So it is a wrapper, not a rewrite: it reads the table the server produced. Header cells
become sort controls and gain a resize handle, an injected (or adopted) <colgroup>
carries the widths, and the rows are re-ordered in place. Any table in the catalog gets
both behaviours without knowing this component exists.
<TableColumns storageKey="gl-detail" onOpenRow={(i) => setDrilldown(i)}>
<GridKeyboard>
<LedgerTable … />
</GridKeyboard>
</TableColumns>
Sorting
- Three states, not two — ascending, descending, and back to the ledger's own order. The source order of a journal is itself information; a table you can sort but never un-sort has taken it away.
- Numbers and dates open at the big end, text opens at A. The question behind clicking an amount column is "what is the largest", never "what is the smallest", and a first click that yields the oldest row reads as a bug.
- Sorts the value, not the presentation —
data-sort, then thedata-rawthatMoneyalready stamps, then the text.($1,240.56)sorted as text sorts by its opening parenthesis. nullis not0. Blanks sort last in both directions. Flipping them to the top on a descending sort is how a reviewer concludes twelve accounts have no balance when nobody has told us yet.- Stable. Rows that tie keep the order the ledger gave them — date, then entry number.
- Status columns sort by workflow, not alphabet. A–Z puts
cancelledaboveoverdue.
What it refuses to sort
- A hierarchy.
data-levelrows (a chart of accounts) ordata-row-variantrows (subtotals, section heads) carry meaning in their order — a subtotal floating away from the rows it totals is worse than an unsorted table. Those tables still resize. - A table that opted out.
data-sortable="false"on the<table>, declared by the component rather than the caller: a register's running balance is only a running balance in date order, and the register is what knows that. tfoot. Totals are not rows of the population.- A page of a larger set. Sorting the page answers "largest amount" with the largest
of ninety-four rows out of twelve thousand, and looks exactly like the truth. Pass
sortable={false}and sort at the source.
Resizing
Account names, memos and vendor names have no natural length: the width that fits one client's chart of accounts truncates another's on every row, and two truncated account names look identical. That is a correctness problem wearing a layout problem's clothes.
- A 9px hit area centred on the seam, with a 1px line that paints only on hover, focus or during the drag. A permanently visible grabber on every boundary turns a ledger into a grid of handles.
- The handle is a real
separatorwitharia-valuenow. Arrows move it 16px,Alt+arrows 1px,Homerestores the shipped width — and so does a double-click. - Measured from the start of the drag, never accumulated per move; an accumulated delta drifts away from the cursor.
- Persisted per user per table on pointer-up, never per move. Stored widths are clamped to today's bounds and dropped for columns that no longer exist — the table's shape outlives the preference.
- The slack goes to one column, the widest at first paint, which is reliably the label column. A fixed-layout table narrower than its card leaves a gap, and a ruled card with a gap in it reads as a rendering bug.
- A body cell clips; a figure cell does not. A truncated label is an inconvenience the reader can undo. A figure missing its leading digit is a number that is wrong.
Row detail
onOpenRow fires on double-click and on Enter — both, because double-click is the
gesture people bring from every ledger they have used and Enter is the one that works
without a mouse. Pair it with DrilldownSheet or TransactionDetail.
172. ColumnHeader
The same two behaviours for a table that renders its own rows — TransactionTable uses
it. The sort control is a real <button> inside the cell rather than a handler on the
<th>, because the resize handle beside it needs its own focus stop; aria-sort stays
on the <th>, where a screen reader looks for it. The direction chevron always occupies
its space — a glyph that arrives on sort shifts the label sideways on every click of the
column the eye is trying to read.
58. ExportMenu
CSV / XLSX / PDF. States the scope explicitly — "Export 1,284 filtered transactions" — because exporting the current page when the user expected the full filtered set is a silent data error. Includes the resolved period and filters in the exported file's header.
173. PeriodGrid
The ruled grid: rows down, periods across, and the six spreadsheet behaviours an accountant
performs on a workbook within a minute of opening it. Every period-by-period surface in the
system — ForecastTable, BudgetVsActual, RevenueTracker, ExpenseTracker, DriverModel,
ScenarioCompare, PlByDimension — is a configuration of this one grid rather than another
table written from scratch.
Ruled cells, and why the statements are not
A statement is read across one row at a time, and its horizontal rules carry meaning — single above a subtotal, double under a total. A period grid is read down a column as often as across a row, and a column with no left edge is one the eye loses the moment two neighbouring figures happen to be the same width. So the grid boxes every cell and the statements keep their accounting rules with a vertical seam added between period columns. Two different documents, two different rulings, one reason each.
border-collapse: separate is required rather than preferred: a sticky cell in a collapsed
table loses its borders when it detaches, because the border belongs to the table and the cell
scrolls away from it.
Freeze panes
Top row and left column, the two anyone freezes in every workbook they open. The freeze shadow appears only once something is hidden behind it — a permanent shadow on a grid that fits its container is a scroll cue for a scroll that cannot happen.
Hidden columns
Nobody reviews fourteen months at once. Hiding is not deleting: rows index into the full column
list, so a hidden column never renumbers the data behind it, and a declared comparison
(column.compare) still measures against its base while that base is off screen. The count of
hidden columns is always shown with a one-click path back — a reader who hid six columns last
quarter needs to know this grid is not the whole story.
The change, printed under the figure
The figure answers "how much"; the next question is always "versus what". That answer is printed under every figure at once — small, toned, right-aligned — rather than revealed one cell at a time on hover. The reason anyone opens this grid is to compare, and a comparison you can only make one cell at a time is not a comparison. The pointer and the keyboard cursor still fill the formula bar under the grid with the full sentence, including the amount and the column being measured against.
The sub-line is a percentage, not an amount: the amounts are already on screen directly above and to the left, and the percentage is the thing the eye cannot do for itself while scanning seven columns. On a percentage row it is points instead — calling a move from 51.8% to 53.0% "+2.3%" is the most common way a deck lies by accident.
No invented percentages. No ratio against a zero base, none against a negative base, and a missing figure stays missing. The sub-line says which of the three it was instead of printing a number that reads backwards, and the first column on screen shows an en dash rather than a zero.
Annotations, not views
Two independent toggles — % change, and % of a named base row — each adding a line under the figures. Neither replaces them. A segmented Value / Δ / Δ% switch forces the reader to hold one of the two numbers they are comparing in their head while they look at the other, and a grid whose numbers are swapped out for percentages under a tab press is a grid that has to be re-read every time it is touched.
Typing
Off by default: a grid over posted figures is a report, and a report that accepts typing is one
someone will believe they have changed. Where a caller marks cells editable — the projected
inputs of a driver model, never its actuals or its computed rows — a cell opens on double-click
or Enter and accepts what a spreadsheet accepts, including 1.2k, (1,240) and =1200+340.
A value it cannot parse commits nothing rather than committing zero.
Keyboard and clipboard
One tab stop, then arrow keys, PageUp/PageDown, Home/End, shift to extend a range and
⌘C to copy it. The clipboard carries data-raw values, so the paste sums in the spreadsheet it
is going back to.