diff --git a/apps/www/src/components/playground/amount-examples.tsx b/apps/www/src/components/playground/amount-examples.tsx
index 40f10fca5..f5f2e2d98 100644
--- a/apps/www/src/components/playground/amount-examples.tsx
+++ b/apps/www/src/components/playground/amount-examples.tsx
@@ -46,6 +46,28 @@ export function AmountExamples() {
/>
+
+
+ Compact:
+
+
+ Narrow symbol:{' '}
+
+
+
+ Gain:
+
+
+ Loss:
+
+
+ Proportional figures:
+
+
);
diff --git a/apps/www/src/content/docs/components/amount/demo.ts b/apps/www/src/content/docs/components/amount/demo.ts
index f98876517..16566ff47 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
@@ -123,6 +137,44 @@ export const currencyDisplayDemo = {
`
};
+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: `
diff --git a/apps/www/src/content/docs/components/amount/index.mdx b/apps/www/src/content/docs/components/amount/index.mdx
index c166afe6d..655e3ef3e 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,
@@ -62,6 +65,24 @@ Formats and displays monetary values with locale and currency support.
+### notation
+
+Use `compact` to abbreviate large values, e.g. `$1.2M`. Handy for dashboards and summary views. Compact rounds values by design, so avoid it where the exact amount matters.
+
+
+
+### signDisplay
+
+Control when the `+`/`-` sign appears. Useful for showing gains and losses.
+
+
+
+### tabularNums
+
+Tabular (fixed-width) figures are on by default so amounts align digit-for-digit when stacked in table rows. Turn it off in running text, where proportional figures look more natural.
+
+
+
### hideCurrency
Render only the formatted number, without any currency symbol, code, or name. Locale-driven separators and decimal places are preserved.
diff --git a/packages/raystack/components/amount/__tests__/amount.test.tsx b/packages/raystack/components/amount/__tests__/amount.test.tsx
index 978f9ad9c..8683effaf 100644
--- a/packages/raystack/components/amount/__tests__/amount.test.tsx
+++ b/packages/raystack/components/amount/__tests__/amount.test.tsx
@@ -321,4 +321,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 caps fraction digits aggressively: 12.99 → "$13".
+ render();
+ expect(screen.getByText('$13')).toBeInTheDocument();
+ });
+
+ it('works with hideDecimals', () => {
+ render();
+ expect(screen.getByText('$1M')).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', () => {
+ const { container } = render();
+ const span = container.querySelector('span');
+ expect(span?.className).toContain('tabular');
+ });
+
+ it('omits tabular figures when tabularNums is false', () => {
+ const { container } = render();
+ const span = container.querySelector('span');
+ expect(span?.className).not.toContain('tabular');
+ });
+
+ it('keeps custom className alongside the tabular class', () => {
+ const { container } = render(
+
+ );
+ const span = container.querySelector('span');
+ expect(span?.className).toContain('custom-class');
+ expect(span?.className).toContain('tabular');
+ });
+ });
});
diff --git a/packages/raystack/components/amount/amount.module.css b/packages/raystack/components/amount/amount.module.css
index ec87c783d..6b4d71a10 100644
--- a/packages/raystack/components/amount/amount.module.css
+++ b/packages/raystack/components/amount/amount.module.css
@@ -1,3 +1,3 @@
-.amount {
+.tabular {
font-variant-numeric: tabular-nums;
}
diff --git a/packages/raystack/components/amount/amount.tsx b/packages/raystack/components/amount/amount.tsx
index c30f1e2ac..8e850d9c8 100644
--- a/packages/raystack/components/amount/amount.tsx
+++ b/packages/raystack/components/amount/amount.tsx
@@ -52,9 +52,27 @@ export interface AmountProps extends ComponentProps<'span'> {
/**
* Currency display format
* @default 'symbol'
- * @example 'symbol' - $12.99, 'code' - USD 12.99, 'name' - 12.99 US Dollars
+ * @example 'symbol' - $12.99 (may show "US$" in non-US locales), 'narrowSymbol' - $12.99 (always the narrow symbol), 'code' - USD 12.99, 'name' - 12.99 US Dollars
*/
- currencyDisplay?: 'symbol' | 'code' | 'name';
+ currencyDisplay?: 'symbol' | 'narrowSymbol' | 'code' | 'name';
+
+ /**
+ * Number formatting notation.
+ * 'compact' abbreviates large values — useful for dashboards and summary views.
+ * Compact rounds aggressively by design; avoid it when the value must render exactly.
+ * @default 'standard'
+ * @example
+ * 'standard' - $1,200,000.00, 'compact' - $1.2M
+ */
+ notation?: 'standard' | 'compact';
+
+ /**
+ * When to show the +/- sign — useful for gains/losses.
+ * @default 'auto'
+ * @example
+ * 'auto' - -$12.99, 'always' - +$12.99, 'exceptZero' - +$12.99 but $0.00, 'never' - $12.99
+ */
+ signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never';
/**
* Number of minimum fraction digits
@@ -83,41 +101,76 @@ export interface AmountProps extends ComponentProps<'span'> {
* => "12.99"
*/
hideCurrency?: boolean;
+
+ /**
+ * Use fixed-width (tabular) figures so digits align vertically across rows —
+ * ideal for tables and lists of amounts. Set to false in running text,
+ * where proportional figures look more natural.
+ * @default true
+ */
+ tabularNums?: boolean;
}
/**
- * Get the number of decimal places for a currency
+ * Intl.NumberFormat construction is expensive, and Amount often renders
+ * hundreds of times in a table. Cache instances module-wide, keyed by
+ * locale + options. The cap guards against unbounded growth when
+ * locales/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
+ * Sized to hold every valid ISO 4217 code (~180) without eviction;
+ * the cap only guards against unbounded growth from invalid inputs.
*/
-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();
+
+/**
+ * Resolve a currency's validity and decimal places in one cached lookup.
+ */
+function getCurrencyInfo(currency: string): CurrencyInfo {
+ const code = currency.toUpperCase();
+ let info = currencyInfoCache.get(code);
+ if (!info) {
+ try {
+ const formatter = getFormatter('en', {
+ style: 'currency',
+ currency: code
+ });
+ info = {
+ valid: true,
+ decimals: formatter.resolvedOptions().maximumFractionDigits ?? 2
+ };
+ } catch {
+ info = { valid: false, decimals: 2 };
+ }
+ if (currencyInfoCache.size >= CURRENCY_INFO_CACHE_LIMIT) {
+ currencyInfoCache.clear();
+ }
+ currencyInfoCache.set(code, info);
}
+ return info;
}
/**
@@ -152,9 +205,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 +222,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 +244,15 @@ 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.valid
+ ? currencyInfo.decimals
+ : getCurrencyInfo('USD').decimals;
/**
* Convert minor → major units.
@@ -240,12 +304,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
@@ -269,14 +335,14 @@ export const Amount = ({
);
return (
-
+
{formattedValue}
);
} catch (error) {
console.error('Error formatting amount:', error);
return (
-
+
{String(value)}
);