Ledger & tables
The table is the product. It gets the same design attention as the hero card.
47. TransactionTable#
The workhorse. Every detail of it is a deliberate choice.
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 |
Almost nobody builds the 768–1024 pattern, and it's what makes a wide table usable at laptop width. The label and the number stay pinned while the metadata scrolls between them, so the two things being compared are always on screen. Sticky cell backgrounds, IntersectionObserver edge fades, inner borders and unclipped focus rings are covered 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 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; it happens too often. Every column sorts on its
value: amounts on minor units, status on workflow urgency instead of 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 setup 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: one row height plus a 1px top rule. It's the number people are 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 move between cells, and the grid is one tab stop. Table in accessibility.
Motion#
Tier 0 almost entirely. Row hover is background-color at 100ms, with no transform, shadow or 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. It shows 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 are two separate columns, never one signed column. Collapsing them stops the reader from verifying, which is the whole purpose of double-entry. Column totals show the balance check, and 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, runs on the same two primitives. An accountant doesn't distinguish between them; they come from a spreadsheet and press the arrow keys.
useGridNavfor tables that are already client components (TransactionTable).GridKeyboardwraps the ones that are deliberately server-rendered and reads the DOM they produced instead of forcing a rewrite. A 400-row statement shouldn't 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 HTML, so every paste target gets its best format.- 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 shouldn't lose the account name. - Every column sorts and drags.
ColumnHeaderfor the tables that render their own rows,TableColumnsfor the ones that render on the server. Same split, same reason asuseGridNavandGridKeyboard.
52. AgingTable#
AR/AP by bucket: current / 1-30 / 31-60 / 61-90 / 90+. Row totals and column totals. The grand total cell is proven both ways: rows and columns must agree, and the component asserts it.
asOf — the date the buckets were aged to — is stated under the title ("Aged as of 30 Sep 2026 · USD"). Every bucket edge is counted from it, so it is the aging's basis.
Bucket severity uses a sequential lightness ramp, not a red gradient, so it survives grayscale 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, because 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. The user is trying to get that difference 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 do the other 40 like it saves more time than anything else 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, allowed because it's occasional.
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, for the same reason. Most ledger tables here are server
components, because a 400-row statement shouldn't ship 400 rows of JavaScript to be rendered
twice. But bookkeepers come from Excel, where every column sorts and drags, and rewriting
nineteen tables as client components is the worse trade.
So it wraps the table the server produced instead of replacing it. Header cells become sort
controls with a resize handle, an injected (or adopted) <colgroup> carries the widths, and
rows are reordered in place. Any table in the catalog gets both behaviors 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. A journal's source order is information, and a table you can't un-sort loses it.
- Numbers and dates open at the big end, text opens at A. The question behind clicking an amount column is "what's the largest", never "what's the smallest", and a first click that shows the oldest row looks like a bug.
- Sorts the value, not the presentation:
data-sort, then thedata-rawthatMoneyalready stamps, then the text. Sorted as text,($1,240.56)sorts by its opening parenthesis. nullis not0. Blanks sort last in both directions. Flip them to the top on a descending sort and a reviewer concludes twelve accounts have no balance, when the balances just aren't known 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>, set by the component, not the caller. A register's running balance only means something in date order, and the register 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 it looks exactly right. 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's a correctness problem, not just a layout one.
- A 9px hit area centered on the seam, with a 1px line that paints only on hover, focus or drag. A 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, andHomeor a double-click restores the shipped width. - Measured from the start of the drag, never accumulated per move, which 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, because 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 looks like 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 wrong.
Row detail#
onOpenRow fires on double-click and on Enter. People bring double-click from every
ledger they've used, and Enter works without a mouse. Pair it with DrilldownSheet or TransactionDetail.
172. ColumnHeader#
The same two behaviors for a table that renders its own rows; TransactionTable uses
it. The sort control is a real <button> inside the cell, not 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 reserves its
space, so the label doesn't shift sideways each time the column is sorted.
179. GroupPicker#
Chooses how the table is cut. One cut, not a stack. Nested grouping (category inside entity inside month) leaves about four data rows on screen under three levels of headers, and what it really wants to be is a pivot table, a different tool with a different layout. One cut with a subtotal per group answers the question people actually ask.
<GroupPicker
options={[
{ key: "category", label: "Category", hint: "Subtotal per account category" },
{ key: "vendor", label: "Vendor" },
]}
value={groupBy}
onValueChange={setGroupBy}
/>
- "No grouping" is the first choice in the list and where the tick starts, not a separate
clear button. Ungrouped is a view like the others, and readers return to it constantly.
value={undefined}means it;noneLabelrenames it for the table. - The trigger says what's in force: "Grouped by vendor", not "Group". If the label never changes, the reader has to open the control to find out.
- Each option can say what its subtotal will mean (
hint), and the popover states the rule under the list: each group subtotals the rows under it, not the filtered set behind them. - The tick keeps its column whether or not it's drawn, so labels don't shift as the selection moves. Choosing closes the popover; the popover opens from its trigger.
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 behaviors an accountant
uses within a minute of opening a workbook. Every period-by-period surface in the system
(ForecastTable, BudgetVsActual, RevenueTracker, ExpenseTracker, DriverModel,
ScenarioCompare, PlByDimension) is a configuration of this grid, not 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, and the eye loses a column with no left edge as soon as two neighboring figures are the same width. So the grid boxes every cell, and the statements keep their accounting rules with a vertical seam between period columns.
border-collapse: separate is required. In a collapsed table a sticky cell 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 everyone freezes. The freeze shadow appears only once something is hidden behind it. A permanent shadow on a grid that fits its container hints at a scroll that can't happen.
Hidden columns#
Nobody reviews fourteen months at once. Hiding isn't deleting: rows index into the full column
list, so a hidden column never renumbers the data, and a declared comparison (column.compare)
still measures against its base while the base is off screen. The hidden-column count is always
shown with a one-click way back, because a reader who hid six columns last quarter needs to know
the grid isn't 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 and right-aligned, instead of revealed cell by cell on hover. People open this grid to compare, and a comparison you can make only one cell at a time isn't one. The pointer and the keyboard cursor still fill the formula bar under the grid with the full sentence, including the amount and the column it's measured against.
The sub-line is a percentage, not an amount. The amounts are already on screen above and to the left; the percentage is what the eye can't work out while scanning seven columns. On a percentage row it shows points instead. Calling a move from 51.8% to 53.0% "+2.3%" is the most common way a deck misleads by accident.
No invented percentages. No ratio against a zero base or a negative base, and a missing figure stays missing. The sub-line says which case it was instead of printing a number that reads backwards, and the first column on screen shows an en dash, not a zero.
Annotations, not views#
Two independent toggles, % change and % of a named base row, each add a line under the figures without replacing them. A segmented Value / Δ / Δ% switch makes the reader hold one number in their head while looking at the other, and a grid that swaps its numbers for percentages on a tab press has to be re-read every time it's touched.
Typing#
Off by default. A grid over posted figures is a report, and if a report accepts typing,
someone will believe they changed it. Where a caller marks cells editable (a driver model's
projected inputs, never its actuals or 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
can't parse commits nothing, not 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.
API change: PeriodRow.polarity is now required, no default (rows without one previously fell back to standard). Polarity describes the figure as stored: a cost stored negative is standard, a cost stored positive is inverted, a headcount is neutral.
182. BankStatement#
The bank's statement, proved: opening → every line → closing, against the bank's own figure.
<BankStatement
institution="Fernhill Bank"
accountType="Business checking"
last4="4021"
period={{ start: new Date(2026, 8, 1), end: new Date(2026, 8, 30) }}
openingBalance={money(4_812_045)}
closingBalance={money(3_414_029)} // as the bank printed it
lines={lines} // date order, as printed
/>
type BankStatementLine = {
id: string;
date: Date;
description: string;
reference?: string;
/** Exactly one is set. Both unsigned: the column is the sign. */
withdrawal: MoneyValue | null;
deposit: MoneyValue | null;
/** The running balance the bank printed beside the line, if it printed one. */
balance?: MoneyValue | null;
};
Withdrawals and deposits are two columns, never one signed amount column, as debit and credit
are. The running balance is walked from the opening figure in minor units and is only a running
balance in date order, so the table declares data-sortable="false".
The proof sits below the lines, outside the scroller, so it never scrolls away: opening
balance · plus deposits · less withdrawals · = closing balance, computed · closing balance per
bank · difference. The labels carry the operation and the figures stay unsigned. A zero
difference closes on the double rule with a positive confirmation. A non-zero one turns the
border --negative and states that a line is missing or doubled.
Finding the line. Where the bank printed its own running balance, the first line at which the walk departs from it is marked, with the bank's figure in words beneath. That's where the missing or doubled line sits. Only the first is marked, because every line after it is off by the same amount and marking those too would bury the one that matters.
Per-currency decimals. One statement is one currency. Figures take their decimals from it (EUR 2, JPY 0, KWD 3), and nothing is ever summed across statements.
Responsive (table collapse):
- ≥ md: the table. Date frozen left, header frozen, description clamps at two lines,
figures never truncate, the scroller capped by
maxHeight. - < md: a list, not a sideways scroll. Description and amount lead. Date and reference sit beneath the description; the kind of movement ("Deposit", "Withdrawal") and the running balance sit beneath the amount. The kind is said in words, never carried by a sign.
Header: institution mark (EntityMark), account type, masked number, statement period,
currency: the basis of every figure below it.
Motion: none beyond the row hover tint (fine pointer only). A statement is a document.