StatementTable
- Status
- Implemented
- Family
- 6 of 13
- Registry
- @lopifi/statement-table
npx shadcn@latest add @lopifi/theme @lopifi/statement-tableThe component, rendering
Real data, the shipped component. Switch the theme to check the claims the spec makes below.
The four-layer spec
The shared shell. P&L, balance sheet, and cash flow are all configurations of this.
Structure
┌─ Toolbar: basis · period · comparison · currency · consolidation ─┐
├───────────────────────────────────────────────────────────────────┤
│ Account Sep 2026 Aug 2026 Change % │ ← sticky header
├───────────────────────────────────────────────────────────────────┤
│ Revenue │ section
│ Product revenue 482,100 441,200 +40,900 +9.3% │ L2
│ Subscriptions 390,400 360,100 +30,300 +8.4% │ L3
│ One-time 91,700 81,100 +10,600 +13.1% │ L3
│ Services 118,400 102,800 +15,600 +15.2% │ L2
│ ───────── ───────── ───────── │ ← single rule
│ Total revenue 600,500 544,000 +56,500 +10.4% │ subtotal
│ │
│ … │
│ ═════════ ═════════ ═════════ │ ← double rule
│ Net income 142,880 118,400 +24,480 +20.7% │ total
└───────────────────────────────────────────────────────────────────┘
The craft
- Indentation: 24px per level (16px on phone). Levels 1–5. Deeper than 5 is a chart-of-accounts problem, not a UI problem.
- Rule lines span the numeric columns only, each period column independently, gutters unruled. This is the detail that makes it read as an accounting statement rather than a styled table.
- Sticky account column (left) and sticky header (top). Both, always. A P&L you scroll horizontally without the account names is unreadable.
- Sticky total row at the bottom for the grand total.
- Expand/collapse is instant — hundreds of times per close. The chevron rotates 140ms; the rows do not slide, grow, or stagger. This is the Tier 0 concession.
- Expansion state is in the URL, so a shared link opens to the same view.
- Zero values render as
—. Null renders asn/a. They are different facts. - Right-aligned,
tnum,case, paren column reserved. Negatives in parentheses, always — this is a statement. - Every column header states its period explicitly. "Sep 2026", never "Current".
- The statement header carries the full context: entity, statement type, resolved period, basis (cash/accrual), presentation currency, and generation timestamp. Required for print, and required for the number to mean anything.
Responsive
One period column at a time on phone, with a swipeable period selector and a fixed account column. Values crossfade at 140ms with no translation — translation would imply the labels moved too. Full spec in responsive.
Accessibility
Real table semantics, <th scope="row"> on account names, <caption> with the resolved period, data-level for hierarchy (not aria-level, which is invalid outside a treegrid), aria-expanded/aria-controls on toggles, and subtotal/total status in the row header text — the rule line is invisible to a screen reader. Full spec in accessibility.
Repeating headers, break-inside: avoid on rows, break-before: avoid on totals so a total never orphans from its rule.
Lifted verbatim from Statements. 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/statement-table.tsx
- Lopifi dependencies · 13
- cncolumn-managerfiscal-periodformat-moneyiconsmoneymoney.typespercentperiod-changeruletooltipuse-scroll-edgesvariance-cell
- npm packages
- @base-ui-components/reactclass-variance-authorityclsxtailwind-merge
- Registry entry
- /r/statement-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 * as React from "react";
import { useScrollEdges } from "@/hooks/ledger/use-scroll-edges";
import { cn } from "@/lib/ledger/cn";
import { formatDate } from "@/lib/ledger/fiscal-period";
import { formatMoney } from "@/lib/ledger/format-money";
import type { AccountingBasis, Polarity } from "@/lib/ledger/money.types";
import { periodChange } from "@/lib/ledger/period-change";
import { ColumnManager, type ManagedColumn } from "@/components/ledger/column-manager";
import { ChevronRightIcon } from "@/components/ledger/icons";
import { Money } from "@/components/ledger/money";
import { Percent } from "@/components/ledger/percent";
import { Rule } from "@/components/ledger/rule";
import { VarianceCell } from "@/components/ledger/variance-cell";
export type StatementLine = {
id: string;
label: string;
/** 1–5. Deeper than 5 is a chart-of-accounts problem, not a UI problem. */
level: number;
variant: "line" | "subtotal" | "total" | "section" | "margin";
/** One entry per period column. `null` is "we don't have it"; 0 is zero. */
values: (number | null)[];
/** Percent rows are visually distinguished — a percentage in a column of
dollars must not look like dollars. */
percentValues?: (number | null)[];
polarity?: Polarity;
children?: StatementLine[];
/** Opt *out*. Every line with a figure is drillable by default — a figure
you cannot open is a figure you have to take on trust. Set false for
lines with nothing behind them (memo rows, computed ratios). */
drilldownable?: boolean;
};
/** Which figure was clicked. A line drills differently per period, so the
column travels with the line. */
export type DrilldownTarget = {
/** The resolved period label, as shown in the column header. */
period: string;
/** Index into `periods` / `line.values` — survives hidden columns. */
periodIndex: number;
value: number | null;
};
export type StatementHeader = {
entity: string;
statement: string;
/** Resolved, never "Current". */
periodLabel: string;
basis: AccountingBasis;
framework: "us-gaap" | "ifrs";
currency: string;
/** "in thousands" — when presented scaled, the header says so. Not optional. */
scale?: "units" | "thousands" | "millions";
generatedAt: Date;
provenance?: string;
};
export interface StatementTableProps {
className?: string;
/**
* Offer the reader the period columns to hide. A comparative statement with
* five periods and a variance pair is nine columns on a laptop, and the
* first thing anyone does with it is put four of them away.
*/
columnManager?: boolean;
header: StatementHeader;
lines: StatementLine[];
/** Both thresholds must be exceeded for a variance to flag. */
materiality?: { absolute: number; relative: number };
/**
* Caps the scroller so the column headers actually freeze. Without a height
* the page scrolls instead, and `position: sticky` on a header inside a
* container that never scrolls is a header that never sticks — which is
* what a long balance sheet had until this existed.
*/
maxHeight?: number;
onDrilldown?: (line: StatementLine, target: DrilldownTarget) => void;
/** Each column states its own resolved period. */
periods: string[];
restatedColumns?: number[];
roundingNote?: string;
showVariance?: boolean;
}
const SCALE_LABEL = {
millions: "in millions",
thousands: "in thousands",
units: null,
} as const;
/**
* The shared shell. P&L, balance sheet and cash flow are all configurations
* of this.
*
* Rule lines span the numeric columns only, each period column
* independently, gutters unruled. That is the detail that makes it read as
* an accounting statement rather than a styled table.
*
* Expand/collapse is instant — this happens hundreds of times during a
* close. The chevron rotates in 140ms; the rows do not slide or stagger.
*/
export function StatementTable({
header,
periods,
lines,
showVariance = false,
materiality,
onDrilldown,
roundingNote,
restatedColumns = [],
maxHeight,
columnManager = false,
className,
}: StatementTableProps) {
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
const toggle = (id: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
// Expansion state belongs in the URL, so a shared link opens the same view.
try {
const params = new URLSearchParams(window.location.search);
if (next.size) params.set("collapsed", [...next].join(","));
else params.delete("collapsed");
const q = params.toString();
window.history.replaceState(null, "", q ? `?${q}` : window.location.pathname);
} catch {
/* the view still works without a shareable URL */
}
return next;
});
};
const flat = React.useMemo(() => flatten(lines, collapsed), [lines, collapsed]);
const scaleLabel = header.scale ? SCALE_LABEL[header.scale] : null;
/* Column visibility is held as the full list rather than as a set of hidden
ids, so hiding a period never renumbers the values behind it — every row
still indexes into `periods`, and `visible` carries the source index. */
const [managed, setManaged] = React.useState<ManagedColumn[]>(() =>
periods.map((p, i) => ({ id: p, label: p, locked: i === 0, visible: true })),
);
React.useEffect(() => {
setManaged((current) => {
const seen = new Map(current.map((c) => [c.id, c]));
if (periods.length === current.length && periods.every((p) => seen.has(p))) return current;
return periods.map((p, i) => ({
id: p,
label: p,
locked: i === 0,
visible: seen.get(p)?.visible ?? true,
}));
});
}, [periods]);
const visible = React.useMemo(
() =>
managed
.filter((m) => m.visible)
.map((m) => ({ index: periods.indexOf(m.id), label: m.id }))
.filter((c) => c.index >= 0),
[managed, periods],
);
/* The freeze-line cues. One hook across the system — see
`useScrollEdges` for why this is an attribute rather than state. */
const scrollerRef = useScrollEdges<HTMLDivElement>();
return (
<div className={cn("w-full", className)}>
{/* The statement header carries the full context. Required for print,
and required for the numbers to mean anything. */}
<header className="mb-4">
<h2 className="font-display text-title-lg">{header.entity}</h2>
<p className="text-title-sm font-medium">{header.statement}</p>
<p className="text-body text-text-secondary">{header.periodLabel}</p>
<p className="text-caption text-text-tertiary">
{header.basis === "cash" ? "Cash basis" : "Accrual basis"} ·{" "}
{header.framework === "ifrs" ? "IFRS" : "US GAAP"} · {header.currency}
{scaleLabel ? ` · ${scaleLabel}` : ""} · Generated {formatDate(header.generatedAt)}
{header.provenance ? ` · ${header.provenance}` : ""}
</p>
</header>
{columnManager && periods.length > 1 ? (
<div className="mb-2 flex items-center gap-2">
<ColumnManager columns={managed} onChange={setManaged} />
{managed.length - visible.length > 0 ? (
<button
className="text-caption text-text-secondary underline decoration-dotted underline-offset-2 hover:text-text-primary"
onClick={() => setManaged((c) => c.map((m) => ({ ...m, visible: true })))}
type="button"
>
{managed.length - visible.length} period
{managed.length - visible.length === 1 ? "" : "s"} hidden — show all
</button>
) : null}
</div>
) : null}
<div
className={cn(
"ledger-statement-grid overflow-auto rounded-xl border border-border bg-surface scrollbar-thin",
)}
ref={scrollerRef}
style={maxHeight ? { maxHeight } : undefined}
>
<table className="w-full min-w-[640px]">
<caption className="sr-only">
{header.statement} for {header.entity}, {header.periodLabel}, {header.basis} basis, in{" "}
{header.currency}
</caption>
{/* The freeze lives on the cells, not on the `thead`. A sticky
`thead` in a `border-collapse: separate` table offsets its row
from the scroller's top edge by the row's own leading, and the
rows underneath show through the gap — which is the one thing a
frozen header may not do. The cells stick individually instead;
the CSS is in `.ledger-statement-grid`. */}
<thead>
<tr className="border-b border-border-subtle">
<th
className="sticky left-0 z-sticky min-w-[220px] bg-surface px-6 py-3 text-left text-label font-medium text-text-secondary"
scope="col"
>
Account
</th>
{visible.map(({ label: p, index: i }) => (
<th
className="min-w-[120px] px-3 py-3 text-right text-label font-medium text-text-secondary"
key={p}
scope="col"
>
{p}
{restatedColumns.includes(i) ? (
<span className="block text-caption font-normal text-warning">Restated</span>
) : null}
</th>
))}
{showVariance ? (
<>
<th
className="min-w-[120px] px-3 py-3 text-right text-label font-medium text-text-secondary"
scope="col"
>
Change
</th>
<th
className="min-w-[100px] px-6 py-3 text-right text-label font-medium text-text-secondary"
scope="col"
>
%
</th>
</>
) : null}
</tr>
</thead>
<tbody>
{flat.map((line) => (
<StatementRowInner
collapsed={collapsed.has(line.id)}
currency={header.currency}
key={line.id}
line={line}
materiality={materiality}
onDrilldown={onDrilldown}
onToggle={() => toggle(line.id)}
periods={visible}
showVariance={showVariance}
/>
))}
</tbody>
</table>
</div>
{roundingNote ? <p className="mt-2 text-caption text-text-tertiary">† {roundingNote}</p> : null}
</div>
);
}
function flatten(lines: StatementLine[], collapsed: Set<string>, out: StatementLine[] = []): StatementLine[] {
for (const line of lines) {
out.push(line);
if (line.children && !collapsed.has(line.id)) flatten(line.children, collapsed, out);
}
return out;
}
/** A period column that survived the column manager, with its source index. */
type VisibleColumn = { label: string; index: number };
function StatementRowInner({
line,
periods,
currency,
collapsed,
onToggle,
showVariance,
materiality,
onDrilldown,
}: {
line: StatementLine;
periods: VisibleColumn[];
currency: string;
collapsed: boolean;
onToggle: () => void;
showVariance?: boolean;
materiality?: { absolute: number; relative: number };
onDrilldown?: (line: StatementLine, target: DrilldownTarget) => void;
}) {
const isTotal = line.variant === "total";
const isSubtotal = line.variant === "subtotal";
const isSection = line.variant === "section";
const isMargin = line.variant === "margin";
const hasChildren = Boolean(line.children?.length);
// The rule line is invisible to a screen reader, so the row header text
// carries the subtotal/total status.
const rowLabel = isTotal
? `${line.label}, grand total`
: isSubtotal
? `${line.label}, subtotal`
: line.label;
const indent = 24 * (line.level - 1);
return (
<>
{/* The single rule above a subtotal spans each period column
independently, with the gutters unruled. */}
{(isSubtotal || isTotal) && !isSection ? (
<tr aria-hidden="true" data-row-variant={line.variant}>
<td className="sticky left-0 bg-surface" />
{periods.map((p) => (
<td className="px-3" key={p.label}>
<Rule variant="subtotal" />
</td>
))}
{showVariance ? (
<>
<td className="px-3">
<Rule variant="subtotal" />
</td>
<td className="px-6" />
</>
) : null}
</tr>
) : null}
<tr
className={cn(
"border-b border-border-subtle",
isSection && "border-t border-border-subtle",
// Never both a rule and a background fill on a total row.
"transition-colors duration-1 ease-standard hover:bg-surface-hover",
)}
data-row-variant={line.variant}
>
<th
className={cn(
"sticky left-0 z-sticky bg-[inherit] py-3 pr-4 text-left font-normal",
isSection && "text-title-sm font-medium",
(isTotal || isSubtotal) && "font-medium",
isMargin && "text-text-secondary",
)}
data-level={line.level}
scope="row"
style={{ paddingLeft: 24 + indent }}
>
<span className="flex items-center gap-1.5">
{hasChildren ? (
<button
aria-controls={`${line.id}-children`}
aria-expanded={!collapsed}
aria-label={`${collapsed ? "Expand" : "Collapse"} ${line.label}`}
className="press grid size-5 shrink-0 place-items-center rounded-xs text-text-tertiary hover:bg-surface-hover"
onClick={onToggle}
type="button"
>
<ChevronRightIcon
aria-hidden="true"
className={cn(
"size-3.5 transition-transform duration-2 ease-standard",
!collapsed && "rotate-90",
)}
/>
</button>
) : (
<span aria-hidden="true" className="size-5 shrink-0" />
)}
<span className="no-hyphens">{rowLabel}</span>
</span>
</th>
{periods.map((p, position) => {
const value = line.values[p.index] ?? null;
const percent = line.percentValues?.[p.index];
/* The comparison is the next column along, which in a comparative
statement is the older period — the one the reader is implicitly
asking "against what" about. */
const against = periods[position + 1];
const prior = against ? (line.values[against.index] ?? null) : null;
const change = periodChange(value, prior, line.polarity ?? "standard");
/* Any figure opens — the affordance is the figure itself, tinted on
hover, rather than a "Drill down" link repeated down the label
column. Percent and n/a cells have nothing behind them. */
const drillable =
Boolean(onDrilldown) && line.drilldownable !== false && !isSection && !isMargin && value !== null;
const figure = isSection ? null : isMargin && percent != null ? (
<Percent signed={false} value={{ kind: "points", value: percent }} />
) : value === null ? (
<span className="numeric relative text-figure-sm text-text-tertiary">
<span aria-hidden="true">n/a</span>
<span className="sr-only select-none">not available</span>
</span>
) : (
<Money accounting size={isTotal ? "md" : "sm"} statement value={{ amount: value, currency }} />
);
return (
<td className={cn("group relative text-right", drillable ? "p-0" : "px-3 py-3")} key={p.label}>
{drillable ? (
<button
aria-label={`${line.label}, ${p.label} — show the entries behind this figure`}
className="ledger-figure px-3 py-3"
onClick={() => onDrilldown?.(line, { period: p.label, periodIndex: p.index, value })}
type="button"
>
{figure}
</button>
) : (
figure
)}
{/* The hover answer. CSS-only, so a four-hundred-row statement
does not re-render on every pointer move — and hidden from
assistive technology, because the same change is already in
the variance columns and in the row's own reading. */}
{!isSection && change.absolute != null && against ? (
<span aria-hidden="true" className="ledger-grid-chip hidden text-caption group-hover:block">
<span
className={cn(
"numeric font-medium",
change.direction === "favourable"
? "text-positive"
: change.direction === "unfavourable"
? "text-negative"
: "text-text-secondary",
)}
>
{change.relative == null
? change.absolute === 0
? "no change"
: `${change.absolute > 0 ? "+" : "−"}${formatMoney({
amount: Math.abs(change.absolute),
currency,
})}`
: `${change.relative > 0 ? "+" : change.relative < 0 ? "−" : ""}${Math.abs(
change.relative,
).toFixed(1)}%`}
</span>{" "}
<span className="text-text-tertiary">vs {against.label}</span>
</span>
) : null}
</td>
);
})}
{showVariance ? (
<VarianceCell
currency={currency}
current={periods[0] ? (line.values[periods[0].index] ?? null) : null}
hidden={isSection}
materiality={materiality}
polarity={line.polarity ?? "standard"}
prior={periods[1] ? (line.values[periods[1].index] ?? null) : null}
/>
) : null}
</tr>
{/* The double rule below a grand total. Nothing is summed past here. */}
{isTotal ? (
<tr aria-hidden="true">
<td className="sticky left-0 bg-surface" />
{periods.map((p) => (
<td className="px-3" key={p.label}>
<Rule variant="total" />
</td>
))}
{showVariance ? (
<>
<td className="px-3">
<Rule variant="total" />
</td>
<td className="px-6" />
</>
) : null}
</tr>
) : null}
</>
);
}
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.
- Balance sheetAssets, liabilities and equity at a point in time, proving the accounting equation
- Chart of accountsThe account structure
- Net incomeRevenues and gains less expenses and losses, including income tax, for the period — the bottom line of the P&L, before any allocation to non-controlling interests
- P&LPerformance over a period
- PeriodA fiscal reporting window
- Presentation currencyThe currency the statements are shown in. ASC 830 calls it the reporting currency; IAS 21 the presentation currency — we use the latter
- StatementP&L, balance sheet, or cash flow
- TranslationConverting an entity's functional-currency statements into the presentation currency — assets and liabilities at the closing rate, income and expense at average — with the difference recorded in OCI as the CTA
Also in Statements
- StatementRowIndent level, subtotal, total, expand.
- ProfitAndLossP&L with period comparison and variance.
- BalanceSheetAssets / liabilities / equity, with the balance check.
- CashFlowStatementOperating / investing / financing.
- StatementToolbarBasis, period, comparison, currency, consolidation.
- PeriodPickerFiscal-aware. Never assumes January.
- VarianceCellAbsolute + percent + significance flag.
- FluxAnalysisPeriod-over-period explanations.
- DrilldownSheetStatement line → the GL entries behind it.
- TrialBalanceAll accounts with the debit=credit proof.
- CloseChecklistTasks, owners, blockers, sign-off.
See also
A component is specified in one place, defined in another, and running in a third. All three, from here.
- Statements — the family specThe four-layer specification this component was built against, before it was written.Doc
- Financial statement componentsThe family running, with real figures in it, in both themes and at every density.Live
- CatalogAll 179 components, specified before they are written.Doc
- Balance sheetAssets, liabilities and equity at a point in time, proving the accounting equationTerm
Elsewhere in the system
Where StatementTable 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.
- StatementTable, runningThe statements 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
- StatementsThe largest gap in the market. No component library ships a <BalanceSheet />. This family is the reason Lopifi exists. Specs for 12 React components: StatementTable, StatementRow, ProfitAndLoss, BalanceSheet and 8 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 Statements 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.