Skip to content

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 correct aria-rowcount / aria-rowindex so 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-subtle row 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 + onSortChange and 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, with description taking the slack. See ColumnHeader.
  • 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 --warning left 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.

  • useGridNav for tables that are already client components (TransactionTable). GridKeyboard wraps 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/PageDown move a screen, Home/End the row, Space selects, Enter opens, Escape collapses.
  • 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. Money stamps every figure with data-raw, so a copied column lands in Excel as -1240.56 and 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. ColumnHeader for the tables that render their own rows, TableColumns for the ones that render on the server — the same split, and the same reason, as useGridNav and GridKeyboard.

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 the data-raw that Money already stamps, then the text. ($1,240.56) sorted as text sorts by its opening parenthesis.
  • null is not 0. 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 cancelled above overdue.

What it refuses to sort

  • A hierarchy. data-level rows (a chart of accounts) or data-row-variant rows (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 separator with aria-valuenow. Arrows move it 16px, Alt+arrows 1px, Home restores 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.

The 16 components specified here.

Built against this document

The surfaces that implement what this argues — running, computing, or shaped by it.

Told when this spec changes

The docs are the contract the components are built against. When one changes, the component changes with it — and the copy in your repo does not. One email when that happens.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.