Fintech & Payments

Ledger Table

A double-entry ledger with separate debit and credit columns, a computed running-balance column, hairline dividers, tabular figures, and a sticky header.

Install

npx shadcn@latest add @paragon/ledger-table

ledger-table.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface LedgerEntry {
  /** Unique id for the row key. */
  id: string;
  /** Posting date, pre-formatted, e.g. "Jul 03". */
  date: string;
  /** Description / memo line. */
  description: string;
  /** Optional reference (invoice #, check #). */
  reference?: string;
  /** Debit amount in major units, or null when this line is a credit. */
  debit?: number | null;
  /** Credit amount in major units, or null when this line is a debit. */
  credit?: number | null;
}

export interface LedgerTableProps extends React.ComponentProps<"div"> {
  entries: LedgerEntry[];
  /** Opening balance the running column starts from. */
  openingBalance?: number;
  /** ISO currency code for formatting. */
  currency?: string;
  /** Intl locale. */
  locale?: string;
  /** Constrains height to activate the sticky header. */
  maxHeight?: number | string;
}

/**
 * A double-entry ledger with separate debit and credit columns and a computed
 * running-balance column. The header sticks to the top of the scroll area so
 * column meaning survives long registers; rows separate with hairline dividers
 * and every figure is tabular so the columns align to the decimal. The running
 * balance is derived from the opening balance and each line's net — no motion,
 * because a ledger is read hundreds of times a day.
 */
export function LedgerTable({
  entries,
  openingBalance = 0,
  currency = "USD",
  locale = "en-US",
  maxHeight = 360,
  className,
  ...props
}: LedgerTableProps) {
  const fmt = React.useMemo(
    () =>
      new Intl.NumberFormat(locale, {
        style: "currency",
        currency,
      }),
    [locale, currency],
  );

  const rows = React.useMemo(() => {
    let balance = openingBalance;
    return entries.map((e) => {
      const debit = e.debit ?? 0;
      const credit = e.credit ?? 0;
      balance += credit - debit;
      return { ...e, balance };
    });
  }, [entries, openingBalance]);

  const closing = rows.length ? rows[rows.length - 1].balance : openingBalance;

  return (
    <div
      className={cn(
        "w-full overflow-auto rounded-xl bg-card shadow-border",
        className,
      )}
      style={{ maxHeight }}
      {...props}
    >
      <table className="w-full border-separate border-spacing-0 text-sm">
        <thead>
          <tr>
            <th className="sticky top-0 z-10 border-b bg-card px-4 py-2.5 text-left text-xs font-medium whitespace-nowrap text-muted-foreground">
              Date
            </th>
            <th className="sticky top-0 z-10 border-b bg-card px-4 py-2.5 text-left text-xs font-medium whitespace-nowrap text-muted-foreground">
              Description
            </th>
            <th className="sticky top-0 z-10 border-b bg-card px-4 py-2.5 text-right text-xs font-medium whitespace-nowrap text-muted-foreground">
              Debit
            </th>
            <th className="sticky top-0 z-10 border-b bg-card px-4 py-2.5 text-right text-xs font-medium whitespace-nowrap text-muted-foreground">
              Credit
            </th>
            <th className="sticky top-0 z-10 border-b bg-card px-4 py-2.5 text-right text-xs font-medium whitespace-nowrap text-muted-foreground">
              Balance
            </th>
          </tr>
        </thead>
        <tbody className="[&>tr:last-child>td]:border-b-0">
          {rows.map((row) => (
            <tr
              key={row.id}
              className="transition-colors duration-(--duration-fast) hover:bg-muted/40"
            >
              <td className="border-b px-4 py-2.5 align-top whitespace-nowrap tabular-nums text-muted-foreground">
                {row.date}
              </td>
              <td className="border-b px-4 py-2.5 align-top">
                <span className="block">{row.description}</span>
                {row.reference && (
                  <span className="block text-[12px] tabular-nums text-muted-foreground">
                    {row.reference}
                  </span>
                )}
              </td>
              <td className="border-b px-4 py-2.5 text-right align-top tabular-nums">
                {row.debit ? (
                  <span className="text-foreground">{fmt.format(row.debit)}</span>
                ) : (
                  <span className="text-muted-foreground/40">—</span>
                )}
              </td>
              <td className="border-b px-4 py-2.5 text-right align-top tabular-nums">
                {row.credit ? (
                  <span className="text-success">{fmt.format(row.credit)}</span>
                ) : (
                  <span className="text-muted-foreground/40">—</span>
                )}
              </td>
              <td className="border-b px-4 py-2.5 text-right align-top font-medium tabular-nums">
                {fmt.format(row.balance)}
              </td>
            </tr>
          ))}
        </tbody>
        <tfoot>
          <tr>
            <td
              colSpan={4}
              className="border-t px-4 py-2.5 text-right text-xs font-medium text-muted-foreground"
            >
              Closing balance
            </td>
            <td className="border-t px-4 py-2.5 text-right text-sm font-semibold tabular-nums">
              {fmt.format(closing)}
            </td>
          </tr>
        </tfoot>
      </table>
    </div>
  );
}