Numerics
The document that makes this a finance system rather than a dashboard kit. Everything here is a rule the rest of the library enforces on the consumer's behalf.
1. Storage: minor units, always
Money is an integer of minor units plus a currency code. Never a float. Never a
numberalone.
type Money = {
/** Integer amount in the currency's minor unit. 1234567 = $12,345.67 */
amount: bigint | number;
/** ISO 4217 */
currency: string;
/** Minor-unit exponent. Defaults from the ISO table; override for crypto/high-precision. */
scale?: number;
};
0.1 + 0.2 !== 0.3. In a ledger that means a reconciliation that never balances and a support ticket you cannot reproduce. Use integers, or a decimal library (dinero.js v2, decimal.js) — but never raw floats, and never do arithmetic in the component layer at all.
Lopifi components never do arithmetic. They format and lay out. Totals are passed in, already computed by the caller, who owns the rounding policy. A component that sums its own rows will eventually disagree with the backend, and the backend is right.
Decimal places are per-currency
JPY has 0. USD/EUR/GBP have 2. KWD/BHD/JOD/TND have 3. Crypto has up to 18. Hard-coding toFixed(2) breaks in every one of these cases except the 2-decimal ones.
const digits = new Intl.NumberFormat(locale, { style: "currency", currency })
.resolvedOptions().maximumFractionDigits;
Let Intl answer for most currencies, with one small override table: Intl reports CLDR display digits, and in V8 (Chrome, Edge, Node) about seventeen currencies — HUF, IDR, COP, PKR, IQD among them — come back with 0 where the ISO 4217 minor unit is 2 (3 for IQD). currencyScale() carries those exceptions, so HUF 12,345 minor units renders as 123.45 Ft, not 12,345 Ft.
2. Formatting
new Intl.NumberFormat(locale, {
style: "currency",
currency,
currencyDisplay: "narrowSymbol", // "$100" not "US$100"
currencySign: "accounting", // (100.00) instead of -100.00
}).format(amount / 10 ** scale);
Two options doing heavy lifting:
currencyDisplay: "narrowSymbol"—$rather thanUS$,£rather thanGB£. Use"symbol"(which disambiguates) only in a multi-currency context where two dollar currencies coexist; then render the code as a separate, subordinate element instead:$1,200.00 USD.currencySign: "accounting"— hands you parentheses natively, locale-correctly.
Locale ≠ currency
en-US formatting a EUR amount gives €1,234.56. de-DE gives 1.234,56 €. Both correct; they're different questions. Format in the viewer's locale, in the value's currency. A German accountant reviewing a US subsidiary sees 1.234,56 $, and that is right.
Consistency within a column beats per-value optimization
Fixed decimals throughout: $1,200.00, never $1,200. Intl does this by default; don't undo it.
3. Negatives
Two conventions, chosen by context
| Context | Format | Why |
|---|---|---|
| Statements, ledgers, tables | (1,234.00) |
Accounting standard. The reader is scanning a column and parentheses are unmissable. |
| Deltas, KPIs, inline prose | −$1,234.00 |
A delta is a change, not a balance. An explicit sign reads faster in isolation. |
Both are colored --negative, and neither relies on color — the parens and the sign are the primary signal.
Use the real minus
− U+2212 MINUS SIGN, not - U+002D hyphen-minus. The hyphen is shorter, sits lower, and is a different width from the plus sign — so +12.4% and -12.4% don't align in a column. Intl emits U+2212 already; the bug is introduced when someone hand-builds a string.
Negative alignment
The problem: in a right-aligned column mixing 1,234.00 and (1,234.00), the closing paren shifts the digits ~4px left and destroys decimal alignment.
Three fixes, in order of preference:
Reserve the paren column. Render an invisible
)on positive values.<span aria-hidden className="invisible">)</span>Cheapest, works everywhere, keeps copy-paste clean if you strip
aria-hiddencontent on copy.Grid the cell.
display: grid; grid-template-columns: auto 1fr auto;— open paren / figure / close paren. Most robust for mixed content.Pad-right the cell by the width of
)(padding-right: 1chon positives). Works only withtnum, where1chis a real fixed unit.
Same treatment for the +/− prefix column in a delta list: reserve the glyph slot so values align even when some are unchanged.
Sign is not sentiment
Restated from color: a component takes polarity ("standard" | "inverted" | "neutral"). A negative expense variance is good. Never assume down is bad.
4. Zero, null, and redacted — three different things
The most common correctness bug in financial UI, and the cheapest to fix.
| State | Glyph | Meaning | Screen reader |
|---|---|---|---|
| True zero | — (em dash) in statements; $0.00 in tables and inputs |
We know it, and it's zero | "zero" |
| Not applicable | empty cell | This line cannot have a value (e.g. a header row) | nothing |
| Unknown / not loaded | n/a at --text-tertiary |
We don't have it | "not available" |
| Redacted | •••• at --text-tertiary |
We have it, you can't see it yet | "hidden, activate to reveal" |
| Loading | structural skeleton | In flight | aria-busy="true" |
Rendering
$0.00for a value you don't have is a lie, and in a financial product it is the kind of lie that costs money. The type system should make it impossible:Money | null | undefinedwith three explicit render branches, nevervalue ?? 0.
The em dash for statement zeroes is the accounting convention — it says "computed, and nil", distinct from a blank, which says "not applicable here". Lopifi's StatementRow implements all three.
5. Abbreviation
$1.2M is a chart-and-KPI device. It is forbidden in any surface a person might rely on as a record.
| Surface | Abbreviation |
|---|---|
| Chart axis labels | ✅ Required — full values won't fit |
| KPI card hero figure | ✅ Allowed, with the exact value in a tooltip |
| Sparkline endpoint labels | ✅ Allowed |
| Table cells | ❌ Never |
| Statement lines | ❌ Never |
| Any total or subtotal | ❌ Never |
| Amount inputs | ❌ Never |
| Receipts, confirmations, exports | ❌ Never |
Rules when abbreviating:
- Thresholds:
< 1,000full ·≥ 1e3K·≥ 1e6M·≥ 1e9B·≥ 1e12T - One decimal place max, and drop it when it's
.0:$1.2M,$5M— not$5.0M - Never abbreviate below 10K.
$1.2Kis harder to read than$1,200and saves three characters. - Uppercase
K/M/B. Lowercasekis an engineering convention, not a financial one. - Use
Intl.NumberFormat(locale, { notation: "compact" })for correct localization (1,2 Mio.in German), not string surgery. - The exact value must always be reachable —
title, tooltip, or on hover. An abbreviated number with no way to see the real one is a dead end.
6. Rounding
The sum-of-rounded problem
Round each of 33.333, 33.333, 33.334 to two decimals and the parts sum to 100.00. Round 0.005 values and they won't. This is the single most reported "bug" in finance UI, and it isn't a bug — it's arithmetic.
Policy: display rounding is a lie you must not tell twice.
- Compute in minor units at full precision.
- Round once, at the display boundary.
- If rounded parts must sum to a rounded total, apply largest-remainder allocation: round everything down, then distribute the remaining minor units one at a time to the items with the largest fractional remainders.
function allocate(total: number, weights: number[]): number[] {
const sum = weights.reduce((a, b) => a + b, 0);
const raw = weights.map(w => (total * w) / sum);
const floored = raw.map(Math.floor);
let remainder = total - floored.reduce((a, b) => a + b, 0);
return raw
.map((r, i) => ({ i, frac: r - floored[i] }))
.sort((a, b) => b.frac - a.frac)
.reduce((acc, { i }) => (remainder-- > 0 ? (acc[i]++, acc) : acc), [...floored]);
}
Use it for fee splits, tax allocation, percentage-of-total columns, and pie-chart labels. Lopifi provides this helper and uses it in CategoryBreakdown and PlByDimension.
- When a rounded column genuinely can't sum, say so. A
† roundedfootnote beats a total that's off by a cent with no explanation.StatementTablesupports aroundingNoteslot. - Half-even (banker's) rounding for financial allocation; half-up for display of a single value. Document which you used — in the export, in the tooltip, somewhere.
7. Percentages, rates, basis points
| Type | Format | Precision | Note |
|---|---|---|---|
| Change / growth | +5.3% |
1 dp | Always signed |
| Margin, ratio | 42.7% |
1 dp | Unsigned |
| Interest / APR | 4.250% |
3 dp | Financial convention; trailing zeros kept |
| FX rate | 1.08432 |
5 sig figs | Currency-pair dependent |
| Basis points | +25 bps |
0 dp | Use bps when the change is < 1pp — +0.25% is ambiguous |
Percentage point vs percent — the trap
If a margin goes from 20% to 25%, it rose 5 percentage points (+5pp), which is a 25% increase. These are different numbers and both are correct. Components must be told which:
type PercentChange =
| { kind: "points"; value: number } // renders "+5.0pp"
| { kind: "relative"; value: number } // renders "+25.0%"
Conflating them is how a dashboard reports a 25% margin improvement that didn't happen. Delta requires the discriminator; there is no default.
Division by zero
(x − 0) / 0 is not ∞, it's undefined, and it must render as n/a — never Infinity, never NaN, never 100%. Guard at the format boundary. Growth from a zero base is genuinely unknowable; say so.
8. Rule lines
Four centuries of accounting typography, ~zero web implementations. Two CSS declarations for enormous credibility.
Revenue
Product 482,100
Services 118,400
───────── ← single rule: a subtotal follows
Total revenue 600,500
...
Net income 142,880
═════════ ← double rule: this is final
| Rule | CSS | Meaning |
|---|---|---|
| Single rule above | border-top: 1px solid var(--border) |
The line below is a subtotal of the lines above |
| Double rule below | border-bottom: 3px double var(--border-strong) |
Grand total. Nothing is summed past here. |
| Section rule | border-top: 1px solid var(--border-subtle) full-bleed |
Visual grouping only, no arithmetic meaning |
Details that matter:
- Rules span the numeric column only, not the label column. This is the actual convention and it's what makes it read as accounting rather than as a generic divider. A rule under "Total revenue" that extends to the left margin is a divider; one that starts where the numbers start is a rule.
border-bottom: 3px doublerenders as 1px / 1px / 1px. Exactly right, and it's a single declaration.- In a multi-period statement, the rule spans each period column independently, with the column gap unruled.
- Never both a rule and a background fill on a total row. Pick one — the rule, per convention. A grey "total" row is a spreadsheet habit, not an accounting one.
9. Overflow
A number never truncates. A truncated figure isn't degraded, it's wrong —
$1,234,5…is unreadable and dangerous.
Escalation ladder, in order:
- Step down the figure size one token (
figure-hero→figure-lg→figure-md). Hero figures do this automatically at a character-count threshold, not withclamp()— a container-query size step keeps the value on the type scale instead of landing between tokens. - Drop the cents in display contexts only (never in tables or statements), with the full value in a tooltip.
- Abbreviate — but only in the surfaces §5 allows.
- Wrap the container. Let the card grow. A card that's 8px taller is fine; a wrong number is not.
- Horizontal scroll for the table, with sticky label column and edge fades.
The label truncates. The number does not. That's the priority order, always.
Related: reserve the maximum width. A live-updating figure should occupy the width of its largest plausible value from first paint, so it doesn't reflow as it updates. With tnum this is ch-computable.
10. Multi-currency
- Never sum across currencies without an explicit conversion, and when you do, show the rate and the as-of time. A total labeled
$that silently includes EUR at an unstated rate is a material misstatement. - Mixed-currency columns get the code on every row, subordinated:
1,200.00 EUR. Symbols alone are ambiguous ($ is 20+ currencies). - A converted value is a derived value. Mark it: a
≈prefix, or an interpolated original in the tooltip.≈ $1,304.12with€1,200.00 @ 1.08677 · 6 Sep 2026 09:12 UTCon hover. - Never mix currencies in a chart series. Convert to a stated presentation currency, label the axis with it, and show the conversion basis in the chart footer.
- The FX rate is a perishable fact. Every displayed rate ships with a timestamp, and every rate that will be transacted on ships with a lock and a countdown. See swaps & FX.
11. Dates, periods, and "as of"
- ISO 8601 in data, locale-formatted in UI.
2026-09-06stored,6 Sep 2026rendered viaIntl.DateTimeFormat. - Never
MM/DDorDD/MMunlabeled.09/06/2026is two different days depending on the reader. Use an abbreviated month name — it's unambiguous in every locale and costs one character. - Fiscal ≠ calendar.
FY26 Q1may start in July. Period components take afiscalYearStartMonthand label accordingly; never assume January. - Period labels are explicit and inclusive. "Sep 1 – Sep 30, 2026", not "September" and definitely not "Last 30 days" without the resolved dates in a tooltip.
- Every financial figure carries an "as of". A balance without a timestamp is a rumor. Render it at
caption/--text-tertiary, and use relative time only under an hour ("2m ago"), absolute beyond that. - Timezone is stated. For anything that determines which period a transaction lands in, show the zone.
9:12 UTCnot9:12. - Relative time must update, and pause when hidden. Use a shared interval,
document.visibilityState, and anIntersectionObserver. Forty rows each running their ownsetIntervalis a real performance bug.
12. Debit / credit
For components that render raw ledger entries (LedgerTable, JournalEntry):
- Two columns, never one signed column. Debit and credit are separate columns; that's the whole point of double-entry, and collapsing them destroys the reader's ability to verify.
- A journal entry displays its balance check. Sum of debits = sum of credits, shown, with a clear failure state when it doesn't.
- Sign convention is stated in the UI, not assumed. Whether a revenue credit renders positive or negative depends on the presentation basis; the toolbar says which.
- Contra accounts render in their natural position with the account name carrying the meaning — never as a "negative asset".
13. Sensitive values
- Redaction is a presentation state, not missing data.
••••at--text-tertiary, reveal on explicit user action, andaria-label="Balance hidden, activate to reveal". - A privacy toggle blurs, it doesn't remove.
filter: blur(6px)on the figure withuser-select: none, so layout is stable when it toggles. Keep the blur ≤ 8px and don't animate it beyond a 140ms opacity crossfade. - Account numbers show last 4 only by default:
•••• 4291. The masked portion isaria-hidden; the accessible name is"account ending 4291". - Never log or telemeter a formatted monetary value. Analytics events carry the shape of an interaction, never the amount.
- Copy-to-clipboard copies the raw value, not the formatted one —
1234.56, not$1,234.56. Nobody wants to paste a dollar sign into a spreadsheet.
14. Accessibility of numbers
- Screen readers mangle
$1,234.56inconsistently across engines. Provide an explicit accessible label:
For parenthesized negatives this is mandatory —<span aria-label="1,234 dollars and 56 cents">$1,234.56</span>(1,234.00)is read as "1,234.00" by several engines, silently dropping the sign. That is a correctness failure, not a nicety. Announce"negative 1,234 dollars". aria-hiddenon the invisible alignment paren and on any decorative arrow glyph.- A live-updating figure uses
aria-live="polite"on the container, and is throttled — announcing every tick is unusable. Announce at most once every 10s, or on a meaningful threshold change. - Column headers in every numeric table use
<th scope="col">so a screen reader announces the column when reading a cell. In a 12-column P&L this is the difference between usable and not.