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.
npx shadcn@latest add @paragon/account-statement"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>
);
}