diff --git a/apps/www/src/content/docs/components/amount/demo.ts b/apps/www/src/content/docs/components/amount/demo.ts
index f98876517..09fec0d56 100644
--- a/apps/www/src/content/docs/components/amount/demo.ts
+++ b/apps/www/src/content/docs/components/amount/demo.ts
@@ -32,9 +32,19 @@ export const playground = {
},
currencyDisplay: {
type: 'select',
- options: ['symbol', 'code', 'name'],
+ options: ['symbol', 'narrowSymbol', 'code', 'name'],
defaultValue: 'symbol'
},
+ notation: {
+ type: 'select',
+ options: ['standard', 'compact'],
+ defaultValue: 'standard'
+ },
+ signDisplay: {
+ type: 'select',
+ options: ['auto', 'always', 'exceptZero', 'never'],
+ defaultValue: 'auto'
+ },
minimumFractionDigits: {
type: 'number',
defaultValue: undefined
@@ -50,6 +60,10 @@ export const playground = {
hideCurrency: {
type: 'checkbox',
defaultValue: false
+ },
+ tabularNums: {
+ type: 'checkbox',
+ defaultValue: true
}
},
getCode
@@ -117,19 +131,59 @@ export const currencyDisplayDemo = {
code: `
{/* $12.99 */}
+ {/* US$12.99 */}
+ {/* $12.99 */}
{/* USD 12.99 */}
{/* 12.99 US dollars */}
`
};
+export const notationDemo = {
+ type: 'code',
+ code: `
+
+ {/* $1.2M */}
+ {/* $13K */}
+ {/* $1,200,000.00 */}
+
+ `
+};
+
+export const signDisplayDemo = {
+ type: 'code',
+ code: `
+
+ {/* +$12.99 */}
+ {/* -$12.99 */}
+ {/* $0.00 */}
+ {/* $12.99 */}
+
+ `
+};
+
+export const tabularNumsDemo = {
+ type: 'code',
+ code: `
+
+ {/* Tabular figures (default) keep digits aligned across rows */}
+
+
+ {/* Proportional figures read better in running text */}
+
+ You saved today
+
+
+ `
+};
+
export const hideCurrencyDemo = {
type: 'code',
code: `
{/* 12.99 */}
{/* 1,299 */}
- {/* 12.99 — currencyDisplay is ignored */}
+ {/* 12.99 (currencyDisplay is ignored) */}
`
};
@@ -174,14 +228,14 @@ export const largeNumbersDemo = {
valueInMinorUnits={false} hideDecimals />{/* $10,000,100,091,636,935 */}
{/*
- BigInt is always treated as major units — valueInMinorUnits is ignored
+ BigInt is always treated as major units, so valueInMinorUnits is ignored
*/}
{/* $9,999,999,999,999,999,999.00 */}
{/*
Numbers exceeding safe integer limit will show warning in console
*/}
- {/* Exceeds Number.MAX_SAFE_INTEGER (~9 × 10^15) — logs a console warning */}
+ {/* Exceeds Number.MAX_SAFE_INTEGER (~9 × 10^15), so it logs a console warning */}
`
};
diff --git a/apps/www/src/content/docs/components/amount/index.mdx b/apps/www/src/content/docs/components/amount/index.mdx
index 1d76bb8f0..09bb6a979 100644
--- a/apps/www/src/content/docs/components/amount/index.mdx
+++ b/apps/www/src/content/docs/components/amount/index.mdx
@@ -12,6 +12,9 @@ import {
localeDemo,
hideDecimalsDemo,
currencyDisplayDemo,
+ notationDemo,
+ signDisplayDemo,
+ tabularNumsDemo,
hideCurrencyDemo,
groupDigitsDemo,
withTextDemo,
@@ -48,10 +51,28 @@ Pass a `value` and a `currency` code. Amount formats it for the active locale us
### Currency display
-How the currency is written: `symbol` (the default, `$`), `code` (`USD`), or `name` (`US dollars`).
+How the currency is written: `symbol` (the default, `$`), `narrowSymbol` (`$`, even in locales where `symbol` shows `US$`), `code` (`USD`), or `name` (`US dollars`).
+### Compact notation
+
+Set `notation="compact"` to abbreviate large values in dashboards and summary views, for example `$1.2M`. Compact notation rounds, so do not use it where the exact amount matters. With `hideDecimals`, it rounds to a whole number, so `$1.55M` shows as `$2M`.
+
+
+
+### Sign display
+
+`signDisplay` sets when the `+` or `-` sign shows. Use `always` to show gains and losses as `+$12.99` and `-$12.99`.
+
+
+
+### Tabular numbers
+
+Amount uses tabular (fixed-width) figures by default, so digits align across table rows. Set `tabularNums={false}` in running text to use proportional figures.
+
+
+
### Number without currency
Render only the formatted number, without any currency symbol, code, or name. Locale-driven separators and decimal places are preserved.
@@ -72,7 +93,7 @@ Set `valueInMinorUnits` when your API returns integer cents, paise, or fils. Amo
### Whole units
-`hideDecimals` rounds to whole units. Use it in dense tables and summary figures where the fraction adds noise rather than precision.
+`hideDecimals` truncates to whole units, so `$12.99` shows as `$12`. Use it in dense tables and summary figures where the fraction adds noise rather than precision.
diff --git a/packages/raystack/components/amount/__tests__/amount.test.tsx b/packages/raystack/components/amount/__tests__/amount.test.tsx
index 978f9ad9c..26765f75b 100644
--- a/packages/raystack/components/amount/__tests__/amount.test.tsx
+++ b/packages/raystack/components/amount/__tests__/amount.test.tsx
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Amount } from '../amount';
+import styles from '../amount.module.css';
describe('Amount', () => {
describe('Basic Rendering', () => {
@@ -98,6 +99,19 @@ describe('Amount', () => {
consoleSpy.mockRestore();
});
+ it('falls back to USD when the currency is not a string', () => {
+ const consoleSpy = vi
+ .spyOn(console, 'warn')
+ .mockImplementation(() => null);
+ // API data can send null even though the prop type is string.
+ render();
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Invalid currency code: null. Falling back to USD.'
+ );
+ expect(screen.getByText('$12.99')).toBeInTheDocument();
+ consoleSpy.mockRestore();
+ });
+
it('handles lowercase currency codes', () => {
render();
expect(screen.getByText('€12.99')).toBeInTheDocument();
@@ -132,6 +146,21 @@ describe('Amount', () => {
expect(screen.getByText('$12')).toBeInTheDocument();
});
+ it('drops the sign when hideDecimals truncates a negative value to zero', () => {
+ render();
+ expect(screen.getByText('$0')).toBeInTheDocument();
+ });
+
+ it('drops the sign when hideDecimals truncates a negative string to zero', () => {
+ render();
+ expect(screen.getByText('$0')).toBeInTheDocument();
+ });
+
+ it('keeps the sign when hideDecimals truncates a value below -1', () => {
+ render();
+ expect(screen.getByText('-$12')).toBeInTheDocument();
+ });
+
it('displays currency as symbol by default', () => {
render();
expect(screen.getByText('$12.99')).toBeInTheDocument();
@@ -224,6 +253,21 @@ describe('Amount', () => {
render();
expect(screen.getByText('-$12.99')).toBeInTheDocument();
});
+
+ it('pads string values shorter than the currency decimals', () => {
+ render();
+ expect(screen.getByText('$0.05')).toBeInTheDocument();
+ });
+
+ it('pads negative string values shorter than the currency decimals', () => {
+ render();
+ expect(screen.getByText('-$0.05')).toBeInTheDocument();
+ });
+
+ it('pads short string values for a 3-decimal currency', () => {
+ render();
+ expect(screen.getByText('0.005')).toBeInTheDocument();
+ });
});
describe('BigInt support', () => {
@@ -321,4 +365,118 @@ describe('Amount', () => {
expect(screen.getByText('12.990')).toBeInTheDocument();
});
});
+
+ describe('narrowSymbol', () => {
+ it('renders the narrow symbol instead of the locale-prefixed one', () => {
+ // en-CA formats USD as "US$12.99" with 'symbol'; narrowSymbol drops the prefix.
+ render(
+
+ );
+ expect(screen.getByText('$12.99')).toBeInTheDocument();
+ });
+
+ it('matches symbol output for the home locale', () => {
+ render();
+ expect(screen.getByText('$12.99')).toBeInTheDocument();
+ });
+ });
+
+ describe('signDisplay', () => {
+ it('always shows the sign when signDisplay is always', () => {
+ render();
+ expect(screen.getByText('+$12.99')).toBeInTheDocument();
+ });
+
+ it('shows the sign except for zero when signDisplay is exceptZero', () => {
+ const { rerender } = render(
+
+ );
+ expect(screen.getByText('+$12.99')).toBeInTheDocument();
+ rerender();
+ expect(screen.getByText('$0.00')).toBeInTheDocument();
+ });
+
+ it('hides the sign for negative values when signDisplay is never', () => {
+ render();
+ expect(screen.getByText('$12.99')).toBeInTheDocument();
+ });
+
+ it('keeps the sign when hideCurrency strips the currency token', () => {
+ render();
+ expect(screen.getByText('+12.99')).toBeInTheDocument();
+ });
+ });
+
+ describe('notation', () => {
+ it('renders compact notation for large values', () => {
+ render();
+ expect(screen.getByText('$1.2M')).toBeInTheDocument();
+ });
+
+ it('renders standard notation by default', () => {
+ render();
+ expect(screen.getByText('$1,200,000.00')).toBeInTheDocument();
+ });
+
+ it('rounds small values to compact defaults (no abbreviation below 1K)', () => {
+ // Compact notation keeps 2 significant digits by default.
+ render();
+ expect(screen.getByText('$13')).toBeInTheDocument();
+ });
+
+ it('rounds the abbreviated value with hideDecimals', () => {
+ render();
+ expect(screen.getByText('$2M')).toBeInTheDocument();
+ });
+
+ it('works with string values', () => {
+ render();
+ expect(screen.getByText('$1.2M')).toBeInTheDocument();
+ });
+
+ it('works with hideCurrency', () => {
+ render();
+ expect(screen.getByText('1.2M')).toBeInTheDocument();
+ });
+
+ it('respects explicit fraction digits', () => {
+ render(
+
+ );
+ expect(screen.getByText('$1.23M')).toBeInTheDocument();
+ });
+ });
+
+ describe('tabularNums', () => {
+ it('applies tabular figures by default', () => {
+ render();
+ const amount = screen.getByText('$12.99');
+ expect(amount).toHaveClass(styles.tabular);
+ expect(amount).not.toHaveClass(styles.proportional);
+ });
+
+ it('applies proportional figures when tabularNums is false', () => {
+ render();
+ const amount = screen.getByText('$12.99');
+ expect(amount).toHaveClass(styles.proportional);
+ expect(amount).not.toHaveClass(styles.tabular);
+ });
+
+ it('keeps custom className alongside the tabular class', () => {
+ render();
+ const amount = screen.getByText('$12.99');
+ expect(amount).toHaveClass('custom-class');
+ expect(amount).toHaveClass(styles.tabular);
+ });
+ });
});
diff --git a/packages/raystack/components/amount/amount.module.css b/packages/raystack/components/amount/amount.module.css
index ec87c783d..cb503ea8d 100644
--- a/packages/raystack/components/amount/amount.module.css
+++ b/packages/raystack/components/amount/amount.module.css
@@ -1,3 +1,7 @@
-.amount {
+.tabular {
font-variant-numeric: tabular-nums;
}
+
+.proportional {
+ font-variant-numeric: proportional-nums;
+}
diff --git a/packages/raystack/components/amount/amount.tsx b/packages/raystack/components/amount/amount.tsx
index 7f8a0deac..30544b8b1 100644
--- a/packages/raystack/components/amount/amount.tsx
+++ b/packages/raystack/components/amount/amount.tsx
@@ -44,17 +44,28 @@ export interface AmountProps extends ComponentProps<'span'> {
locale?: string;
/**
- * Truncates decimal places
+ * Truncates to whole units. With `compact` notation, it rounds the abbreviated value instead.
* @default false
*/
hideDecimals?: boolean;
/**
- * Currency display format
+ * How the currency is written. `narrowSymbol` shows `$` where `symbol` shows `US$`.
* @default 'symbol'
- * @example 'symbol' - $12.99, 'code' - USD 12.99, 'name' - 12.99 US Dollars
*/
- currencyDisplay?: 'symbol' | 'code' | 'name';
+ currencyDisplay?: 'symbol' | 'narrowSymbol' | 'code' | 'name';
+
+ /**
+ * Number notation. `compact` abbreviates and rounds large values, for example `$1.2M`.
+ * @default 'standard'
+ */
+ notation?: 'standard' | 'compact';
+
+ /**
+ * When to show the `+` or `-` sign.
+ * @default 'auto'
+ */
+ signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never';
/**
* Number of minimum fraction digits
@@ -83,41 +94,66 @@ export interface AmountProps extends ComponentProps<'span'> {
* => "12.99"
*/
hideCurrency?: boolean;
+
+ /**
+ * Uses fixed-width figures so digits align across rows. `false` uses proportional figures.
+ * @default true
+ */
+ tabularNums?: boolean;
}
/**
- * Get the number of decimal places for a currency
+ * Creating an Intl.NumberFormat is slow, and a table can render hundreds of
+ * amounts. The cap bounds memory when locales or currencies are dynamic.
*/
-function getCurrencyDecimals(currency: string): number {
- try {
- const formatter = new Intl.NumberFormat('en', {
- style: 'currency',
- currency: currency.toUpperCase()
- });
+const FORMATTER_CACHE_LIMIT = 64;
+const formatterCache = new Map();
- // Format a number and count the decimal places
- const formatted = formatter.format(1); // Get string representation of 1 unit with currency symbol
- const match = formatted.match(/\.([\d]+)/); // Extract the decimal part
- return match ? match[1].length : 0;
- } catch {
- // Default to 2 decimal places
- return 2;
+function getFormatter(
+ locale: string,
+ options: Intl.NumberFormatOptions
+): Intl.NumberFormat {
+ const key = `${locale}|${JSON.stringify(options)}`;
+ let formatter = formatterCache.get(key);
+ if (!formatter) {
+ formatter = new Intl.NumberFormat(locale, options);
+ if (formatterCache.size >= FORMATTER_CACHE_LIMIT) formatterCache.clear();
+ formatterCache.set(key, formatter);
}
+ return formatter;
+}
+
+interface CurrencyInfo {
+ valid: boolean;
+ decimals: number;
}
/**
- * Check if a currency is valid
+ * The cap holds every ISO 4217 code (about 180) and bounds growth from
+ * invalid codes.
*/
-function isValidCurrency(currency: string): boolean {
- try {
- new Intl.NumberFormat('en', {
- style: 'currency',
- currency: currency.toUpperCase()
- });
- return true;
- } catch {
- return false;
+const CURRENCY_INFO_CACHE_LIMIT = 256;
+const currencyInfoCache = new Map();
+
+function getCurrencyInfo(currency: string): CurrencyInfo {
+ let info = currencyInfoCache.get(currency);
+ if (!info) {
+ try {
+ const { maximumFractionDigits } = new Intl.NumberFormat('en', {
+ style: 'currency',
+ currency
+ }).resolvedOptions();
+ info = { valid: true, decimals: maximumFractionDigits ?? 2 };
+ } catch {
+ // Invalid codes fall back to USD, which has 2 decimals.
+ info = { valid: false, decimals: 2 };
+ }
+ if (currencyInfoCache.size >= CURRENCY_INFO_CACHE_LIMIT) {
+ currencyInfoCache.clear();
+ }
+ currencyInfoCache.set(currency, info);
}
+ return info;
}
/**
@@ -152,9 +188,14 @@ function isValidCurrency(currency: string): boolean {
* Amount: // Shows as "$12.99"
*
*
- * // With groupDigits (default is true)
+ * // Compact notation for dashboards
+ *
+ * Revenue: // Shows as "$1.2M"
+ *
+ *
+ * // Signed amounts for gains/losses
*
- * Amount: // Shows as "$129,999,999.99"
+ * Change: // Shows as "+$12.99"
*
* ```
*/
@@ -164,11 +205,14 @@ export const Amount = ({
locale = 'en-US',
hideDecimals = false,
currencyDisplay = 'symbol',
+ notation = 'standard',
+ signDisplay = 'auto',
minimumFractionDigits,
maximumFractionDigits,
groupDigits = true,
valueInMinorUnits = true,
hideCurrency = false,
+ tabularNums = true,
className,
...props
}: AmountProps) => {
@@ -183,12 +227,13 @@ export const Amount = ({
);
}
- const validCurrency = isValidCurrency(currency) ? currency : 'USD';
- if (validCurrency !== currency) {
+ const currencyInfo = getCurrencyInfo(currency);
+ const validCurrency = currencyInfo.valid ? currency : 'USD';
+ if (!currencyInfo.valid) {
console.warn(`Invalid currency code: ${currency}. Falling back to USD.`);
}
- const decimals = getCurrencyDecimals(validCurrency);
+ const { decimals } = currencyInfo;
/**
* Convert minor → major units.
@@ -220,14 +265,15 @@ export const Amount = ({
baseValue = value;
}
- // Remove decimals when hideDecimals is true. BigInt has no decimals, so it's a no-op there.
+ // BigInt has no decimals. Truncating a value between -1 and 0 gives -0,
+ // which formats as "-$0", so both paths drop that sign (`+ 0` turns -0 into 0).
const finalBaseValue: number | string | bigint = !hideDecimals
? baseValue
: typeof baseValue === 'bigint'
? baseValue
: typeof baseValue === 'string'
- ? baseValue.split('.')[0]
- : Math.trunc(baseValue);
+ ? baseValue.split('.')[0].replace(/^-0+$/, '0')
+ : Math.trunc(baseValue) + 0;
/**
* Always format in currency mode, since Intl's currency-style handles fraction digits per the currency,
@@ -240,12 +286,14 @@ export const Amount = ({
style: 'currency',
currency: validCurrency.toUpperCase(),
currencyDisplay,
+ notation,
+ signDisplay,
minimumFractionDigits: hideDecimals ? 0 : minimumFractionDigits,
maximumFractionDigits: hideDecimals ? 0 : maximumFractionDigits,
useGrouping: groupDigits
};
- const formatter = new Intl.NumberFormat(locale, formatOptions);
+ const formatter = getFormatter(locale, formatOptions);
/**
* For hideCurrency, strip the `currency` parts and trim leading/trailing
@@ -272,7 +320,10 @@ export const Amount = ({
{formattedValue}
@@ -283,7 +334,10 @@ export const Amount = ({
{String(value)}