Skip to content

TransactionTable

The workhorse. Virtualized, sticky, priority-collapsing.
Implemented
Status
Implemented
Family
5 of 13
Registry
@lopifi/transaction-table
npx shadcn@latest add @lopifi/theme @lopifi/transaction-table

The component, rendering

Real data, the shipped component. Switch the theme to check the claims the spec makes below.

Preview

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

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.

components/ledger/transaction-table.tsx
"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

See also

A component is specified in one place, defined in another, and running in a third. All three, from here.

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.

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.

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.