Skip to content

StatementTable

The shared shell: hierarchy, periods, rules.
Implemented
Status
Implemented
Family
6 of 13
Registry
@lopifi/statement-table
npx shadcn@latest add @lopifi/theme @lopifi/statement-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 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 as n/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.

Print

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.

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

Also in Statements

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

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.

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.