Foundations / Currency
Currency.
Currency rendering is opinionated. Symbol position, decimal handling, sign convention — locked in across the product.
Conventions
The same rules apply to every surface that renders money — tables, KPIs, receipts, exports.
| Rule | Value | Detail |
|---|---|---|
| Symbol | Prefix · always | The symbol leads the number. We never trail. $1,234.56 — not 1,234.56 $. |
| Thousands separator | Comma · period · space | en-US uses comma, de-DE uses period, fr-FR uses space. Locale decides — code never hand-rolls. |
| Decimal | min 2 · max 2 | Two decimal places. No truncation, no overflow. JPY and other no-decimal currencies are the exception. |
| Sign | Positive bare · negative U+2212 | Positives carry no plus by default. Negatives render with Unicode minus (−), never hyphen-minus (-). |
| Zero | $0.00 | Always rendered. Never $0, never an em-dash. Empty data uses a separate empty-state, not a fake zero. |
Locale-aware rendering
Intl.NumberFormat does the heavy lifting. Pass a locale and a currency code; the symbol position, separator, and decimal precision follow.
| Currency | Locale | Rendered | Notes |
|---|---|---|---|
| USD | en-US | $1,234.56 | Default product locale. |
| EUR | de-DE | 1.234,56 € | Trailing symbol with non-breaking space. |
| GBP | en-GB | £1,234.56 | Same shape as USD. |
| JPY | ja-JP | ¥1,235 | No decimals — the rounding is Intl-native. |
Helper
The product imports a single formatter. Components never call Intl.NumberFormat directly — they call this.
// Single source for currency rendering across the product.
export function formatCurrency(
amount: number,
locale: string = 'en-US',
currency: string = 'USD'
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency
}).format(amount);
}
formatCurrency(1234.56); // "$1,234.56"
formatCurrency(1234.56, 'de-DE', 'EUR'); // "1.234,56 €"
formatCurrency(1234.56, 'en-GB', 'GBP'); // "£1,234.56"
formatCurrency(1234.56, 'ja-JP', 'JPY'); // "¥1,235"Compact rendering
Use the full helper in tables and ledgers; use compact for hero KPIs and dashboards where space is tight and exactness less useful than scale.
Tables · ledgers
$2,408,210
Full precision. Two decimals when fractional.
Dashboards · KPIs
$2.4M
Compact short. One fractional digit max.
// Compact rendering for dashboards and KPIs.
export function formatCurrencyCompact(
amount: number,
locale: string = 'en-US',
currency: string = 'USD'
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 1
}).format(amount);
}
formatCurrencyCompact(2408210); // "$2.4M"
formatCurrencyCompact(982); // "$982"Edge cases
- Trailing zeros stay. $1.00, not $1. Two decimals is the contract for fractional currencies.
- Ranges use an en-dash with non-breaking spaces. $1,000 – $2,000. Both symbols repeat; the dash never wraps.
- No ledger parentheses for negatives. ($240) is an accounting tradition we don’t carry — the Unicode minus (−$240) is clearer for non-accountants and matches our screen-reader output.
- JPY and no-decimal currencies skip the fractional part. Intl handles this — don’t force minimumFractionDigits when the currency disagrees.
- Cross-currency tables. Convert before rendering. Mixing $1,234.56 and ¥1,235 in the same column breaks the row scan.