---
title: "Ledger & tables"
description: "The table is the product. It gets the same design attention as the hero card. Specs for 17 React components: TransactionTable, TransactionRow, TransactionCard, TransactionDetail and 13 more."
canonical: "https://lopifi.com/docs/catalog/ledger-tables"
---

# Ledger & tables

> The table is the product. It gets the same design attention as the hero card. Specs for 17 React components: TransactionTable, TransactionRow, TransactionCard, TransactionDetail and 13 more.

Source: https://lopifi.com/docs/catalog/ledger-tables
Part of: Lopifi — Planned: US GAAP books on autopilot, checked by a CPA (https://lopifi.com)

---

# 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](https://lopifi.com/md/docs/craft/responsive-and-input#table-collapse).

### 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 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` + `onSortChange` and
  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`, with
  `description` taking the slack. See [`ColumnHeader`](https://lopifi.com/md/docs/catalog/ledger-tables#172-columnheader).
- **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 `--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 move between cells, and the grid is **one** tab stop. Table in [accessibility](https://lopifi.com/md/docs/craft/accessibility#data-grid-keyboard-model).

### 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.

- **`useGridNav`** for tables that are already client components (`TransactionTable`).
  **`GridKeyboard`** wraps 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`/`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* HTML, so
  every paste target gets its best format.
- **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 shouldn't lose
  the account name.
- **Every column sorts and drags.** `ColumnHeader` for the tables that render their own
  rows, `TableColumns` for the ones that render on the server. Same split, 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 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](https://lopifi.com/md/docs/motion/catalog). 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.

```tsx
<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 the `data-raw` that
  `Money` already stamps, then the text. Sorted as text, `($1,240.56)` sorts by its
  opening parenthesis.
- **`null` is not `0`.** 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 `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>`, 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 `separator`** with `aria-valuenow`. Arrows move it 16px,
  `Alt`+arrows 1px, and `Home` or 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.

```tsx
<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; `noneLabel` renames 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.

```tsx
<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
/>
```

```ts
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](https://lopifi.com/md/docs/craft/responsive-and-input#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.

