Fintech & Payments

Account Statement

A bank-style statement with a header, running rows that carry the balance after each entry, color-coded credits and debits, and a closing-balance footer.

Install

npx shadcn@latest add @paragon/account-statement

account-statement.tsx

"use client";

import * as React from "react";
import { ArrowDownLeft, ArrowUpRight, Download } from "lucide-react";
import { cn } from "@/lib/utils";

export interface StatementEntry {
  id: string;
  date: string;
  description: string;
  /** Signed amount: positive = credit/deposit, negative = debit. */
  amount: number;
}

export interface AccountStatementProps extends React.ComponentProps<"div"> {
  accountName: string;
  accountNumber: string;
  period: string;
  openingBalance: number;
  entries: StatementEntry[];
  currency?: string;
}

/**
 * A bank-style statement: a header with the account, period, and opening
 * balance, then running rows where each line carries the balance after it
 * (opening + cumulative sum), and a closing-balance footer. Credits and
 * debits are color- and glyph-coded, every figure is tabular so the balance
 * column reads as a ledger, and totals in and out are summarized. Purely
 * presentational — pass entries in chronological order.
 */
export function AccountStatement({
  accountName,
  accountNumber,
  period,
  openingBalance,
  entries,
  currency = "USD",
  className,
  ...props
}: AccountStatementProps) {
  const money = React.useMemo(
    () => new Intl.NumberFormat("en-US", { style: "currency", currency }),
    [currency],
  );
  const signed = (v: number) =>
    `${v < 0 ? "−" : "+"}${money.format(Math.abs(v))}`;

  // Running balance after each entry.
  let running = openingBalance;
  const rows = entries.map((e) => {
    running += e.amount;
    return { ...e, balance: running };
  });
  const closing = running;
  const totalIn = entries
    .filter((e) => e.amount > 0)
    .reduce((s, e) => s + e.amount, 0);
  const totalOut = entries
    .filter((e) => e.amount < 0)
    .reduce((s, e) => s + e.amount, 0);

  return (
    <div
      className={cn(
        "w-full max-w-xl overflow-hidden rounded-xl bg-card shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-start justify-between gap-4 border-b p-5">
        <div>
          <p className="text-sm font-semibold">{accountName}</p>
          <p className="text-[13px] text-muted-foreground tabular-nums">
            {accountNumber}
          </p>
          <p className="mt-2 text-[13px] text-muted-foreground">{period}</p>
        </div>
        <div className="flex flex-col items-end gap-2">
          <div className="text-right">
            <p className="text-[11px] uppercase tracking-wide text-muted-foreground">
              Opening balance
            </p>
            <p className="text-sm font-medium tabular-nums">
              {money.format(openingBalance)}
            </p>
          </div>
          <button
            type="button"
            className="pressable inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-[13px] font-medium shadow-border transition-[box-shadow] duration-150 ease-out hover:shadow-border-hover focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          >
            <Download className="size-3.5" aria-hidden />
            PDF
          </button>
        </div>
      </div>

      <div className="grid grid-cols-[auto_1fr_auto_auto] items-center gap-x-4 border-b px-5 py-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
        <span>Date</span>
        <span>Description</span>
        <span className="text-right">Amount</span>
        <span className="text-right">Balance</span>
      </div>

      <ul>
        {rows.map((r) => {
          const credit = r.amount > 0;
          return (
            <li
              key={r.id}
              className="grid grid-cols-[auto_1fr_auto_auto] items-center gap-x-4 border-b px-5 py-2.5 text-sm transition-colors duration-150 ease-out hover:bg-accent/40"
            >
              <span className="w-12 text-[13px] text-muted-foreground tabular-nums">
                {r.date}
              </span>
              <span className="flex min-w-0 items-center gap-2">
                <span
                  className={cn(
                    "flex size-6 shrink-0 items-center justify-center rounded-full",
                    credit
                      ? "bg-success/10 text-success"
                      : "bg-muted text-muted-foreground",
                  )}
                  aria-hidden
                >
                  {credit ? (
                    <ArrowDownLeft className="size-3.5" />
                  ) : (
                    <ArrowUpRight className="size-3.5" />
                  )}
                </span>
                <span className="truncate">{r.description}</span>
              </span>
              <span
                className={cn(
                  "text-right tabular-nums",
                  credit ? "text-success" : "text-foreground",
                )}
              >
                {signed(r.amount)}
              </span>
              <span className="text-right font-medium tabular-nums">
                {money.format(r.balance)}
              </span>
            </li>
          );
        })}
      </ul>

      <div className="flex items-center justify-between gap-4 px-5 py-3">
        <div className="flex gap-4 text-[13px] tabular-nums">
          <span className="text-success">
            In {money.format(totalIn)}
          </span>
          <span className="text-muted-foreground">
            Out {money.format(Math.abs(totalOut))}
          </span>
        </div>
        <div className="text-right">
          <span className="text-[11px] uppercase tracking-wide text-muted-foreground">
            Closing balance
          </span>
          <p className="text-base font-semibold tabular-nums">
            {money.format(closing)}
          </p>
        </div>
      </div>
    </div>
  );
}