TransactionTable
- Status
- Implemented
- Family
- 5 of 13
- Registry
- @lopifi/transaction-table
npx shadcn@latest add @lopifi/theme @lopifi/transaction-tableThe component, rendering
Real data, the shipped component. Switch the theme to check the claims the spec makes below.
The four-layer spec
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.
Lifted verbatim from Ledger & tables. The doc and this page are the same paragraphs, so they cannot disagree.
What lands in your repository
One component is never one file. Everything below is copied into your project, where you own it.
- Files
components/ledger/transaction-table.tsx
- Lopifi dependencies · 21
- clipboardcncolumn-headerentity-markfiscal-periodformat-moneygroup-rowsiconsmoneymoney.typesreferencestatus-pilltable-sorttooltiptransaction-cardtransaction-tableuse-column-priorityuse-column-widthsuse-densityuse-grid-navuse-table-sort
- npm packages
- @base-ui-components/react@tanstack/react-virtualclass-variance-authorityclsxtailwind-merge
- Registry entry
- /r/transaction-table.json
The whole component
No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.
"use client";
import { Checkbox } from "@base-ui-components/react/checkbox";
import { useVirtualizer } from "@tanstack/react-virtual";
import * as React from "react";
import { useColumnPriority, useScrolledSentinel } from "@/hooks/ledger/use-column-priority";
import { type ColumnSpec, useColumnWidths } from "@/hooks/ledger/use-column-widths";
import { useDensity } from "@/hooks/ledger/use-density";
import { useGridNav } from "@/hooks/ledger/use-grid-nav";
import { useTableSort } from "@/hooks/ledger/use-table-sort";
import type { ClipboardCell } from "@/lib/ledger/clipboard";
import { cn } from "@/lib/ledger/cn";
import { formatDateShort } from "@/lib/ledger/fiscal-period";
import { rawMoney } from "@/lib/ledger/format-money";
import {
byMonthDescending,
bySubtotal,
flattenGroups,
type GroupedItem,
groupRows,
monthBucket,
visibleRows,
} from "@/lib/ledger/group-rows";
import type { MoneyValue, PaymentStatus } from "@/lib/ledger/money.types";
import type { SortState } from "@/lib/ledger/table-sort";
import { ColumnHeader } from "@/components/ledger/column-header";
import { EntityMark } from "@/components/ledger/entity-mark";
import { CheckIcon, ChevronDownIcon, MinusIcon } from "@/components/ledger/icons";
import { Money } from "@/components/ledger/money";
import { Reference } from "@/components/ledger/reference";
import { StatusPill } from "@/components/ledger/status-pill";
import { TransactionCard } from "@/components/ledger/transaction-card";
export type Transaction = {
id: string;
date: Date;
description: string;
merchant?: string;
category: string;
account: string;
accountLast4: string;
status: PaymentStatus;
amount: MoneyValue;
reference: string;
memo?: string;
createdBy?: string;
pending?: boolean;
};
export interface TransactionTableProps {
className?: string;
/**
* Persists column widths per user, under `ledger:cols:{key}`. Omit it and
* the widths last as long as the mount does.
*/
columnStorageKey?: string;
defaultGroupBy?: TransactionGroupKey;
defaultSort?: SortState;
/**
* The unfiltered row count, when a filter is active.
*
* A footer total that silently describes a subset is a data error with the
* same shape as exporting the page when the user expected the whole set —
* which is why `ExportMenu` already states its scope. Pass this and the
* total says what it covers; leave it out and nothing is claimed.
*/
filteredFrom?: number;
/**
* Cut the rows into groups, each with its own header and subtotal.
*
* `undefined` is a flat table, which is what a transaction list should open
* as: grouping is an answer to a question, and the reader has not asked one
* yet. Controlled when `onGroupByChange` is given.
*
* The subtotals are computed from the rows in each group, which is the only
* honest thing they can be — but note that this makes them a statement about
*these* rows and not about the filtered set behind them. Pass
* `filteredFrom` when the two differ and the header says so.
*/
groupBy?: TransactionGroupKey;
/** Told after a ⌘C, so the host can raise a toast. */
onCopyRange?: (rows: number, cols: number, ok: boolean) => void;
onGroupByChange?: (key: TransactionGroupKey | undefined) => void;
onOpen?: (t: Transaction) => void;
onSelectionChange?: (ids: string[]) => void;
onSortChange?: (sort: SortState) => void;
selection?: string[];
/**
* Sort order.
*
* Left alone, the table sorts its own rows. Pass `sort` **and**
* `onSortChange` and the host owns it — which is the correct arrangement the
* moment these rows are a page of a larger set, because sorting the page
* answers "largest amount" with the largest of ninety-four rows out of
* twelve thousand, and looks exactly like the truth.
*/
sort?: SortState;
/** Passed in, already computed — a component that sums its own rows will
eventually disagree with the backend, and the backend is right. */
total: MoneyValue;
transactions: Transaction[];
}
const ROW_HEIGHTS = { comfortable: 48, compact: 32, default: 40 } as const;
/**
* What a transaction list can be cut by.
*
* These five and no more, because each one is a question somebody actually
* arrives with: where did it go (category), which account (account), what
* shape is the month (month), what still needs a human (status), and who did
* we pay (merchant). A grouping by reference or by id would be a grouping by
* a field no two rows share.
*
* Category and merchant come out biggest-first, because the question behind
* them is "where did it all go" and the answer is the top of that list. Month
* comes out newest-first, which is the order a ledger is read in. Status and
* account keep the table's own row order, so the sort the reader chose
* survives the grouping.
*/
export type TransactionGroupKey = "category" | "account" | "month" | "status" | "merchant";
export const TRANSACTION_GROUPS: { key: TransactionGroupKey; label: string }[] = [
{ key: "category", label: "Category" },
{ key: "account", label: "Account" },
{ key: "month", label: "Month" },
{ key: "status", label: "Status" },
{ key: "merchant", label: "Payee" },
];
const GROUP_SPECS: Record<
TransactionGroupKey,
{
keyOf: (t: Transaction) => { key: string; label: string } | null;
compare?: Parameters<typeof groupRows<Transaction>>[1]["compare"];
}
> = {
account: {
keyOf: (t) =>
t.account ? { key: `${t.account}-${t.accountLast4}`, label: `${t.account} ••${t.accountLast4}` } : null,
},
category: {
compare: bySubtotal,
keyOf: (t) => (t.category ? { key: t.category, label: t.category } : null),
},
merchant: {
compare: bySubtotal,
keyOf: (t) => {
const label = t.merchant ?? t.description;
return label ? { key: label, label } : null;
},
},
month: { compare: byMonthDescending, keyOf: (t) => monthBucket(t.date) },
status: { keyOf: (t) => (t.status ? { key: t.status, label: t.status } : null) },
};
/**
* The shipped widths, in px.
*
* Resizable because 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.
* `description` is the flexible one — it takes the slack, and every figure
* column keeps the width it was given.
*/
const COLUMN_SPECS: ColumnSpec[] = [
{ id: "date", max: 160, min: 64, width: 80 },
{ id: "description", max: 640, min: 160, width: 280 },
{ id: "category", min: 80, width: 128 },
{ id: "account", min: 96, width: 144 },
{ id: "status", min: 96, width: 128 },
{ id: "reference", min: 96, width: 160 },
{ id: "amount", max: 240, min: 96, width: 128 },
];
/**
* Workflow order, not alphabetical.
*
* Sorting a status column A–Z puts `cancelled` above `overdue`, which is the
* opposite of the order the work needs doing in. This is an urgency scale —
* high is "needs a human", low is "closed" — so the first click, which opens
* a numeric column at its big end, puts the exceptions on top.
*/
const STATUS_RANK: Record<string, number> = {
cancelled: 0,
draft: 9,
failed: 13,
"in-transit": 5,
matched: 3,
"needs-review": 10,
overdue: 11,
pending: 8,
processing: 7,
reconciled: 1,
returned: 12,
scheduled: 6,
sent: 4,
settled: 2,
};
/** Every column sorts on its value, not on the string the reader sees. */
const SORT_ACCESSORS = {
account: (t: Transaction) => `${t.account} ${t.accountLast4}`,
amount: (t: Transaction) => Number(t.amount.amount),
category: (t: Transaction) => t.category,
date: (t: Transaction) => t.date,
description: (t: Transaction) => t.merchant ?? t.description,
reference: (t: Transaction) => t.reference,
status: (t: Transaction) => STATUS_RANK[t.status] ?? -1,
};
/**
* The workhorse. Virtualized past 100 rows with correct aria-rowcount, four
* responsive presentations, a full grid keyboard model with exactly one tab
* stop, and no zebra striping — row dividers instead, because striping
* doubles the background changes on a scrolling surface and fights with
* hover and selection.
*
* Tier 0 motion almost entirely: row hover is background-color at 100ms.
* Sort, filter and paginate do not animate.
*/
export function TransactionTable({
transactions,
total,
onOpen,
selection = [],
onSelectionChange,
onCopyRange,
filteredFrom,
sort,
defaultSort = null,
onSortChange,
columnStorageKey,
groupBy,
defaultGroupBy,
onGroupByChange,
className,
}: TransactionTableProps) {
const { ref: containerRef, presentation } = useColumnPriority();
const { resolved: density } = useDensity();
const { sentinelRef, scrolled } = useScrolledSentinel();
const scrollRef = React.useRef<HTMLDivElement>(null);
const [expanded, setExpanded] = React.useState<string | null>(null);
const rowHeight = ROW_HEIGHTS[density];
/* Amount sorts on the minor units, never on the rendered "($1,240.56)" —
that string sorts by its opening parenthesis. Status sorts on urgency
rather than the alphabet. Both are the same rule: sort the value. */
const sorting = useTableSort<Transaction>({
accessors: SORT_ACCESSORS,
defaultSort,
onSortChange,
rows: transactions,
sort,
});
const sorted = sorting.sorted;
/* Uncontrolled unless the host asks for control — the same arrangement the
sort uses, and for the same reason: the moment these rows are a page of a
larger set, grouping them is the host's business. */
const [ownGroupBy, setOwnGroupBy] = React.useState(defaultGroupBy);
const activeGroupBy = onGroupByChange ? groupBy : (groupBy ?? ownGroupBy);
const _setGroupBy = (key: TransactionGroupKey | undefined) => {
if (!onGroupByChange) setOwnGroupBy(key);
onGroupByChange?.(key);
};
const [collapsed, setCollapsed] = React.useState<ReadonlySet<string>>(new Set());
/* A key from the last grouping is meaningless under the next one, and a
stale collapse is a group the reader cannot find. */
React.useEffect(() => setCollapsed(new Set()), [activeGroupBy]);
const groups = React.useMemo(() => {
if (!activeGroupBy) return null;
const spec = GROUP_SPECS[activeGroupBy];
return groupRows(sorted, {
amountOf: (t) => Number(t.amount.amount),
compare: spec.compare,
keyOf: spec.keyOf,
});
}, [sorted, activeGroupBy]);
/* One flat list either way, so the virtualiser measures one thing and the
keyboard model counts one thing. Ungrouped, every item is a row. */
const items = React.useMemo<GroupedItem<Transaction>[]>(
() =>
groups
? flattenGroups(groups, collapsed)
: sorted.map((row) => ({
group: { count: sorted.length, key: "", label: "", rows: sorted, subtotal: null },
indexInGroup: 0,
kind: "row" as const,
row,
})),
[groups, collapsed, sorted],
);
/* The rows the reader can actually reach right now. Arrow keys and ⌘C walk
this, not the full set — navigating into a collapsed group would move the
focus ring somewhere nobody can see it. */
const rows = React.useMemo(() => visibleRows(items), [items]);
const toggleGroup = (key: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const widths = useColumnWidths({ columns: COLUMN_SPECS, storageKey: columnStorageKey });
const columns = React.useMemo(() => {
const base = ["date", "description", "category", "account", "status", "amount"];
if (presentation === "full") return [...base.slice(0, 5), "reference", "amount"];
if (presentation === "expandable") return base;
return ["description", "date", "status", "account", "amount"];
}, [presentation]);
/* Which position in `rows` each item holds, so a virtual item can address
the keyboard grid without counting group headers every render. */
const rowIndexOf = React.useMemo(() => {
const map: number[] = [];
let next = 0;
for (const item of items) map.push(item.kind === "row" ? next++ : -1);
return map;
}, [items]);
const virtualizer = useVirtualizer({
count: items.length,
/* A group header is one row tall plus its own breathing room; guessing one
height for both makes the scrollbar lie by a header per group. */
estimateSize: (i) => (items[i]?.kind === "group" ? rowHeight + 8 : rowHeight),
getScrollElement: () => scrollRef.current,
overscan: 12,
});
const allSelected = selection.length === transactions.length && transactions.length > 0;
const someSelected = selection.length > 0 && !allSelected;
const toggleAll = () => onSelectionChange?.(allSelected ? [] : transactions.map((t) => t.id));
const toggleOne = (id: string) =>
onSelectionChange?.(selection.includes(id) ? selection.filter((s) => s !== id) : [...selection, id]);
/* What a cell puts on the clipboard. Money goes across as its raw value —
"-1240.56", never "($1,240.56)" — because a column of accounting-formatted
text does not sum in the spreadsheet it just landed in. */
const getCell = React.useCallback(
(row: number, col: number): ClipboardCell => {
const t = rows[row];
const column = columns[col];
if (!t || !column) return { text: "" };
switch (column) {
case "date":
return { text: t.date.toISOString().slice(0, 10) };
case "description":
return { text: t.merchant ?? t.description };
case "category":
return { text: t.category };
case "account":
return { text: `${t.account} ••${t.accountLast4}` };
case "status":
return { text: t.status };
case "reference":
return { text: t.reference };
case "amount":
return { raw: rawMoney(t.amount), text: rawMoney(t.amount) };
default:
return { text: "" };
}
},
[columns, rows],
);
const grid = useGridNav({
colCount: columns.length,
getCell,
onActivate: (row) => {
const t = rows[row];
if (t) onOpen?.(t);
},
onCopyRange,
onEscape: () => onSelectionChange?.([]),
onToggleSelect: (row) => {
const t = rows[row];
if (t) toggleOne(t.id);
},
rowCount: rows.length,
scrollToRow: (row) => virtualizer.scrollToIndex(row, { align: "auto" }),
});
if (presentation === "cards") {
return (
<div className={className} ref={containerRef}>
<ul className="overflow-hidden rounded-xl border border-border bg-surface">
{rows.map((t) => (
<TransactionCard
key={t.id}
onOpen={() => onOpen?.(t)}
onToggle={() => toggleOne(t.id)}
selected={selection.includes(t.id)}
transaction={t}
/>
))}
</ul>
<div className="mt-2 flex items-center justify-between rounded-md border border-border bg-surface px-4 py-3">
<span className="text-label font-medium">Total</span>
<Money accounting size="md" value={total} />
</div>
</div>
);
}
const stickyEdges = presentation === "sticky-edges";
const filtered = filteredFrom != null && filteredFrom !== transactions.length;
return (
<div className={cn("w-full", className)} ref={containerRef}>
<div
className="relative max-h-[520px] overflow-auto rounded-xl border border-border bg-surface scrollbar-thin"
ref={scrollRef}
style={{ overscrollBehavior: "contain" }}
>
<div className="h-px" ref={sentinelRef} />
<table
aria-label="Transactions"
aria-rowcount={transactions.length}
className="w-full border-collapse"
onKeyDown={grid.onKeyDown}
role="grid"
tabIndex={0}
>
<thead
className={cn(
"sticky top-0 z-sticky bg-surface transition-shadow duration-2 ease-standard",
scrolled && "shadow-elevation-1",
)}
>
<tr className="flex w-full items-center">
<th className="w-10 shrink-0 px-3 py-2 text-left" scope="col">
<Checkbox.Root
aria-label="Select all transactions"
checked={allSelected}
className="grid size-4 place-items-center rounded-xs border border-border-strong bg-surface data-[checked]:border-accent data-[checked]:bg-accent data-[indeterminate]:border-accent data-[indeterminate]:bg-accent"
indeterminate={someSelected && !allSelected}
onCheckedChange={toggleAll}
>
<Checkbox.Indicator className="text-accent-fg">
{someSelected ? <MinusIcon className="size-3" /> : <CheckIcon className="size-3" />}
</Checkbox.Indicator>
</Checkbox.Root>
</th>
{columns.map((col) => (
<ColumnHeader
align={col === "amount" ? "right" : "left"}
className={cn(
col === "amount" && "ml-auto",
stickyEdges &&
col === "description" &&
"sticky left-10 z-sticky border-r border-border-subtle bg-surface",
stickyEdges &&
col === "amount" &&
"sticky right-0 z-sticky border-l border-border-subtle bg-surface",
)}
column={col}
direction={sorting.direction(col)}
key={col}
onSort={sorting.toggle}
resizer={
widths.isResizable(col)
? {
active: widths.resizing === col,
max: COLUMN_SPECS.find((c) => c.id === col)?.max,
min: COLUMN_SPECS.find((c) => c.id === col)?.min,
onKeyDown: (e) => widths.onResizeKeyDown(col, e),
onPointerDown: (e) => widths.startResize(col, e),
onReset: () => widths.reset(col),
width: widths.widths[col],
}
: undefined
}
sortable={sorting.isSortable(col)}
style={cellStyle(col, widths.widths[col])}
>
{HEADERS[col] ?? col}
</ColumnHeader>
))}
{presentation === "expandable" ? <th className="w-10 shrink-0" scope="col" /> : null}
</tr>
</thead>
<tbody style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = items[virtualRow.index];
if (!item) return null;
const position = {
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`,
} as const;
/* The group header. It carries the count and the subtotal, so a
collapsed group is still an answer rather than a closed door —
and the count is printed rather than implied, because the
subtotal is a statement about exactly those rows. */
if (item.kind === "group") {
const { group } = item;
return (
<tr
className="absolute left-0 flex w-full items-center border-b border-border bg-surface-sunken"
key={`group-${group.key}`}
style={position}
>
<th
className="flex w-full items-center gap-2 px-3 text-left font-normal"
colSpan={columns.length + 1}
scope="colgroup"
>
<button
aria-expanded={!item.collapsed}
className="flex flex-1 items-center gap-1.5 text-left"
onClick={() => toggleGroup(group.key)}
type="button"
>
<ChevronDownIcon
aria-hidden="true"
className={cn(
"size-4 shrink-0 text-text-tertiary transition-transform duration-2 ease-standard",
item.collapsed && "-rotate-90",
)}
/>
<span className="text-label font-medium">{group.label}</span>
<span className="numeric text-caption text-text-tertiary">
{group.count.toLocaleString()}
</span>
</button>
{group.subtotal === null ? null : (
<Money
accounting
size="sm"
value={{ amount: group.subtotal, currency: total.currency }}
/>
)}
</th>
</tr>
);
}
const t = item.row;
const rowIndex = rowIndexOf[virtualRow.index] ?? 0;
const selected = selection.includes(t.id);
return (
<tr
aria-rowindex={rowIndex + 1}
aria-selected={selected}
className={cn(
"absolute left-0 flex w-full items-center border-b border-border-subtle",
// Row hover is background-color only. No transform, no lift.
"transition-colors duration-1 ease-standard hover:bg-surface-hover",
selected && "bg-surface-selected",
// Pending is the warning rule and the status, never a dimmed
// row: at 60% its secondary text fails contrast.
t.pending && "border-l-2 border-l-[var(--warning)]",
)}
data-pending={t.pending || undefined}
key={t.id}
style={position}
>
<td className="w-10 shrink-0 px-3">
<Checkbox.Root
aria-label={`Select ${t.description}`}
checked={selected}
className="grid size-4 place-items-center rounded-xs border border-border-strong bg-surface data-[checked]:border-accent data-[checked]:bg-accent"
onCheckedChange={() => toggleOne(t.id)}
>
<Checkbox.Indicator className="text-accent-fg">
<CheckIcon className="size-3" />
</Checkbox.Indicator>
</Checkbox.Root>
</td>
{columns.map((col, ci) => (
<Cell
column={col}
focused={grid.cell.row === rowIndex && grid.cell.col === ci}
key={col}
onOpen={() => onOpen?.(t)}
ranged={grid.inRange(rowIndex, ci)}
sticky={
stickyEdges
? col === "description"
? "start"
: col === "amount"
? "end"
: undefined
: undefined
}
transaction={t}
width={widths.widths[col]}
/>
))}
{presentation === "expandable" ? (
<td className="w-10 shrink-0 px-3">
<button
aria-expanded={expanded === t.id}
aria-label={`Show details for ${t.description}`}
className="press grid size-6 place-items-center rounded-xs text-text-tertiary hover:bg-surface-hover"
onClick={() => setExpanded(expanded === t.id ? null : t.id)}
type="button"
>
<ChevronDownIcon
className={cn(
"size-4 transition-transform duration-2 ease-standard",
expanded === t.id && "rotate-180",
)}
/>
</button>
</td>
) : null}
</tr>
);
})}
</tbody>
</table>
</div>
{/* Sticky total footer — the number people are actually looking for,
and the scope it actually covers. */}
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-surface px-4 py-3">
<span className="text-label font-medium">{transactions.length.toLocaleString()} transactions</span>
<span className="flex items-baseline gap-3">
<span className="text-label text-text-secondary">
{filtered ? "Total of filtered set" : "Total"}
</span>
<Money accounting size="md" value={total} />
</span>
{filtered ? (
<span className="w-full text-caption text-text-secondary">
Filtered from <span className="numeric">{filteredFrom?.toLocaleString()}</span> transactions. This
total covers the {transactions.length.toLocaleString()} shown, not all of them.
</span>
) : null}
</div>
</div>
);
}
/**
* Rows are flex (the virtualizer positions them absolutely), so the header row
* must be flex too and share these exact widths. A table-layout header over
* flex rows drifts by a column the moment any cell wraps — which is also why
* the widths are inline numbers rather than utility classes now that they are
* draggable: one source, written into both.
*
* `description` is the only column that grows. The slack in a wide viewport
* has to land somewhere, and it lands on the free-text column rather than
* being spread across the figures, which have no use for it.
*/
function cellStyle(column: string, width?: number): React.CSSProperties {
const spec = COLUMN_SPECS.find((c) => c.id === column);
const resolved = width ?? spec?.width ?? 128;
if (column === "description") {
return { flex: `1 1 ${resolved}px`, minWidth: spec?.min ?? 160 };
}
return { flex: `0 0 ${resolved}px`, width: resolved };
}
const HEADERS: Record<string, string> = {
account: "Account",
amount: "Amount",
category: "Category",
date: "Date",
description: "Description",
reference: "Reference",
status: "Status",
};
function Cell({
column,
transaction: t,
focused,
ranged,
sticky,
width,
onOpen,
}: {
column: string;
transaction: Transaction;
focused: boolean;
ranged: boolean;
sticky?: "start" | "end";
width?: number;
onOpen: () => void;
}) {
const style = cellStyle(column, width);
const base = cn(
"min-w-0 px-3 py-0",
column === "amount" ? "ml-auto text-right" : "text-left",
(column === "category" || column === "account") && "truncate",
// The range tint sits under the focus ring, so the anchor cell reads as
// both "selected" and "where the keyboard is".
ranged && "bg-accent-subtle",
// outline, never box-shadow: a sticky cell must not clip its focus ring.
focused && "outline outline-2 outline-offset-[-2px] outline-[var(--accent)]",
sticky === "start" && "sticky left-10 z-sticky border-r border-border-subtle bg-[inherit]",
sticky === "end" && "sticky right-0 z-sticky border-l border-border-subtle bg-[inherit]",
);
switch (column) {
case "date":
return (
<td className={base} style={style}>
<span className="numeric text-figure-sm text-text-secondary">{formatDateShort(t.date)}</span>
</td>
);
case "description":
return (
<td className={base} style={style}>
<button
className="flex min-w-0 items-center gap-2 text-left"
onClick={onOpen}
tabIndex={-1}
type="button"
>
<EntityMark name={t.merchant ?? t.description} size={20} />
<span className="min-w-0">
{/* The label truncates. The number does not. */}
<span className="block truncate text-body" title={t.description}>
{t.description}
</span>
{t.merchant && t.merchant !== t.description ? (
<span className="block truncate text-caption text-text-secondary">{t.merchant}</span>
) : null}
</span>
</button>
</td>
);
case "category":
return (
<td className={base} style={style}>
<span className="text-body text-text-secondary">{t.category}</span>
</td>
);
case "account":
return (
<td className={base} style={style}>
<span className="text-caption text-text-secondary">
{t.account} <span className="reference-numeric">••{t.accountLast4}</span>
</span>
</td>
);
case "status":
return (
<td className={base} style={style}>
<StatusPill entity="Transaction" status={t.status} />
</td>
);
case "reference":
return (
<td className={base} style={style}>
<Reference value={t.reference} />
</td>
);
case "amount":
return (
<td className={base} style={style}>
<Money accounting colorBySign value={t.amount} />
</td>
);
default:
return <td className={base} style={style} />;
}
}
The accounting it encodes
Concepts this component renders. Each one is defined once, in the lexicon, and used identically in the code and the copy.
Also in Ledger & tables
- TransactionRowDesktop row.Spec
- TransactionCardThe mobile re-rank.
- TransactionDetailFull detail sheet with attachments and audit trail.
- LedgerTableDouble-entry with debit/credit columns and a balance check.
- AgingTableAR/AP buckets, 0-30-60-90+.
- ReconciliationViewTwo-pane matcher. The match animation.
- CategoryPickerHierarchical with rule creation.
- FilterBarURL-param-backed filters with resolved-value chips.
- BulkActionBarSelection count + actions, safe-area aware.
- ColumnManagerShow/hide/reorder with drag.
- GroupPickerOne cut through a table, with subtotals per group.
- ExportMenuCSV/XLSX/PDF with a stated scope.
- TableColumnsSort and resize for a table that renders on the server.
- ColumnHeaderThe sortable, draggable header cell.
- PeriodGridThe ruled grid. Frozen panes, hidden columns, hover change, ⌘C.
See also
A component is specified in one place, defined in another, and running in a third. All three, from here.
- Ledger & tables — the family specThe four-layer specification this component was built against, before it was written.Doc
- Ledger tables and transaction registersThe family running, with real figures in it, in both themes and at every density.Live
- CatalogAll 179 components, specified before they are written.Doc
- ExceptionA break requiring investigationTerm
Elsewhere in the system
Where TransactionTable sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.
- TransactionTable, runningThe tables showcase — live, with theme, density, greyscale and reduced-motion switches.Live
- Number cleaner(1,234.56) · 1.234,56 · 1,234.56- · $ 1 234.56 · 1.2k → clean signed values, one per line.Tool
- Two-list matcherBank against ledger: exact, then reference, then a date window, then one-to-many — and what is left on both sides.Tool
- Reading, paging and delta syncHow do you get everything out, and then keep it fresh?API
- Ledger & tablesThe table is the product. It gets the same design investment as the hero card. Specs for 15 React components: TransactionTable, TransactionRow, TransactionCard, TransactionDetail and 11 more.Doc
Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.
Told when Ledger & tables changes
Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.