diff --git a/apps/www/src/content/docs/components/amount/demo.ts b/apps/www/src/content/docs/components/amount/demo.ts
index 528cd8bd1..3c12b4314 100644
--- a/apps/www/src/content/docs/components/amount/demo.ts
+++ b/apps/www/src/content/docs/components/amount/demo.ts
@@ -46,6 +46,10 @@ export const playground = {
groupDigits: {
type: 'checkbox',
defaultValue: true
+ },
+ hideCurrency: {
+ type: 'checkbox',
+ defaultValue: false
}
},
getCode
@@ -119,6 +123,17 @@ export const currencyDisplayDemo = {
`
};
+export const hideCurrencyDemo = {
+ type: 'code',
+ code: `
+
+ {/* 12.99 */}
+ {/* 1,299 */}
+ {/* 12.99 — currencyDisplay is ignored */}
+
+ `
+};
+
export const groupDigitsDemo = {
type: 'code',
code: `
@@ -149,13 +164,24 @@ export const withTextDemo = {
export const largeNumbersDemo = {
type: 'code',
code: `
-
- {/* For large numbers, use string to maintain precision */}
+
+ {/*
+ For large numbers, use string (supports decimals) or bigint (integer-only)
+ to maintain precision
+ */}
{/* $9,999,999,999,999.99 */}
- {/* $10,000,100,091,636,935 */}
-
- {/* Numbers exceeding safe integer limit will show warning in console */}
- {/* Will show warning */}
+ {/* $10,000,100,091,636,935 */}
+
+ {/*
+ BigInt is always treated as major units — 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 */}
`
};
diff --git a/apps/www/src/content/docs/components/amount/index.mdx b/apps/www/src/content/docs/components/amount/index.mdx
index efc8d66b4..c166afe6d 100644
--- a/apps/www/src/content/docs/components/amount/index.mdx
+++ b/apps/www/src/content/docs/components/amount/index.mdx
@@ -12,6 +12,7 @@ import {
localeDemo,
hideDecimalsDemo,
currencyDisplayDemo,
+ hideCurrencyDemo,
groupDigitsDemo,
withTextDemo,
largeNumbersDemo,
@@ -61,13 +62,19 @@ Formats and displays monetary values with locale and currency support.
+### hideCurrency
+
+Render only the formatted number, without any currency symbol, code, or name. Locale-driven separators and decimal places are preserved.
+
+
+
### groupDigits
### Large Numbers
-For numbers larger than JavaScript's safe integer limit (2^53 - 1), pass the value as a string to maintain precision.
+For numbers larger than JavaScript's safe integer limit (2^53 - 1), pass the value as a `string` (supports decimals) or a `bigint` (integer-only). BigInt values are always treated as already in major units, so `valueInMinorUnits` is ignored for them.
diff --git a/apps/www/src/content/docs/components/amount/props.ts b/apps/www/src/content/docs/components/amount/props.ts
index c6a7fb9e6..5ec8a654b 100644
--- a/apps/www/src/content/docs/components/amount/props.ts
+++ b/apps/www/src/content/docs/components/amount/props.ts
@@ -1,14 +1,18 @@
export interface AmountProps {
/**
- * The monetary value to display
- * For large numbers (> 2^53), pass the value as string to maintain precision
+ * The monetary value to display.
+ * For exact precision beyond 2^53, pass either:
+ * - a `string` — supports decimals (e.g. "1299" or "12.99")
+ * - a `bigint` — integer-only; treated as already in major units, so
+ * `valueInMinorUnits` is ignored when value is a bigint
* @default 0
* @example
* valueInMinorUnits=true: 1299 => "$12.99"
* valueInMinorUnits=false: 12.99 => "$12.99"
- * Large numbers: "999999999999999" => "$9,999,999,999,999.99"
+ * Large strings: "999999999999999" => "$9,999,999,999,999.99"
+ * BigInt: 1299n => "$1,299.00" (always major units)
*/
- value: number | string;
+ value: number | string | bigint;
/**
* ISO 4217 currency code
@@ -65,4 +69,14 @@ export interface AmountProps {
* @default true
*/
groupDigits?: boolean;
+
+ /**
+ * Render the formatted number without a currency symbol, code, or name.
+ * Locale-driven separators, grouping, and fraction digits are preserved.
+ * When true, `currencyDisplay` is ignored.
+ * @default false
+ * @example
+ * => "12.99"
+ */
+ hideCurrency?: boolean;
}
diff --git a/packages/raystack/components/amount/__tests__/amount.test.tsx b/packages/raystack/components/amount/__tests__/amount.test.tsx
index 65c2aa712..bbc804afc 100644
--- a/packages/raystack/components/amount/__tests__/amount.test.tsx
+++ b/packages/raystack/components/amount/__tests__/amount.test.tsx
@@ -203,5 +203,125 @@ describe('Amount', () => {
expect(element).toBeInTheDocument();
consoleSpy.mockRestore();
});
+
+ it('handles negative string values in minor units', () => {
+ render();
+ expect(screen.getByText('-$12.99')).toBeInTheDocument();
+ });
+
+ it('warns when a string longer than 15 digits is formatted', () => {
+ // The component flags potential precision loss for any >15-digit string,
+ // letting the developer decide whether their runtime targets are at risk
+ // and whether to switch to bigint.
+ const consoleSpy = vi
+ .spyOn(console, 'warn')
+ .mockImplementation(() => null);
+ render();
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining('longer than 15 digits')
+ );
+ consoleSpy.mockRestore();
+ });
+ });
+
+ describe('BigInt support', () => {
+ it('formats a bigint as major units regardless of valueInMinorUnits', () => {
+ render();
+ expect(screen.getByText('$1,299.00')).toBeInTheDocument();
+ });
+
+ it('matches the major-units result when valueInMinorUnits is false', () => {
+ render();
+ expect(screen.getByText('$1,299.00')).toBeInTheDocument();
+ });
+
+ it('preserves precision beyond Number.MAX_SAFE_INTEGER', () => {
+ render();
+ expect(
+ screen.getByText('$9,999,999,999,999,999,999.00')
+ ).toBeInTheDocument();
+ });
+
+ it('formats negative bigint values', () => {
+ render();
+ expect(screen.getByText('-$1,299.00')).toBeInTheDocument();
+ });
+
+ it('formats bigint with a zero-decimal currency', () => {
+ render();
+ expect(screen.getByText('¥1,299')).toBeInTheDocument();
+ });
+
+ it('does not warn about safe integer limit for bigint values', () => {
+ const consoleSpy = vi
+ .spyOn(console, 'warn')
+ .mockImplementation(() => null);
+ render();
+ expect(consoleSpy).not.toHaveBeenCalled();
+ consoleSpy.mockRestore();
+ });
+ });
+
+ describe('hideCurrency', () => {
+ it('hides the currency symbol while preserving formatting', () => {
+ render();
+ expect(screen.getByText('12.99')).toBeInTheDocument();
+ });
+
+ it('preserves trailing zeros for round amounts (USD)', () => {
+ render();
+ expect(screen.getByText('12.00')).toBeInTheDocument();
+ });
+
+ it('preserves currency fraction digits for round bigint values', () => {
+ render();
+ expect(screen.getByText('1,299.00')).toBeInTheDocument();
+ });
+
+ it('preserves currency fraction digits for a 3-decimal currency (BHD)', () => {
+ render();
+ expect(screen.getByText('1.234')).toBeInTheDocument();
+ });
+
+ it('overrides currencyDisplay when set', () => {
+ render();
+ expect(screen.getByText('12.99')).toBeInTheDocument();
+ });
+
+ it('respects the currency for decimal-place math even when hidden', () => {
+ render();
+ expect(screen.getByText('1,299')).toBeInTheDocument();
+ });
+
+ it('honors explicit minimumFractionDigits over the currency default', () => {
+ render(
+
+ );
+ expect(screen.getByText('12.0000')).toBeInTheDocument();
+ });
+
+ it('hideDecimals wins over the currency-default fraction digits', () => {
+ render();
+ expect(screen.getByText('12')).toBeInTheDocument();
+ });
+
+ it('clamps min when only maximumFractionDigits is provided (avoids RangeError)', () => {
+ // USD's default min of 2 would invert against max=1; with style:'currency'
+ // + formatToParts(), Intl's spec auto-clamps min → 1, so 12.99 rounds to 13.0.
+ render();
+ expect(screen.getByText('13.0')).toBeInTheDocument();
+ });
+
+ it('clamps max when only minimumFractionDigits is provided (avoids RangeError)', () => {
+ // USD's default max of 2 would invert against min=3; with style:'currency'
+ // + formatToParts(), Intl's spec auto-clamps max → 3, so 12.99 pads to 12.990.
+ render();
+ expect(screen.getByText('12.990')).toBeInTheDocument();
+ });
});
});
diff --git a/packages/raystack/components/amount/amount.tsx b/packages/raystack/components/amount/amount.tsx
index 8ee095e1f..28813109f 100644
--- a/packages/raystack/components/amount/amount.tsx
+++ b/packages/raystack/components/amount/amount.tsx
@@ -2,14 +2,18 @@ import { type ComponentProps } from 'react';
export interface AmountProps extends ComponentProps<'span'> {
/**
- * The monetary value to display
- * For large numbers (> 2^53), pass the value as string to maintain precision
+ * The monetary value to display.
+ * For exact precision beyond 2^53, pass either:
+ * - a `string` — supports decimals (e.g. "1299" or "12.99")
+ * - a `bigint` — integer-only; treated as already in major units, so
+ * `valueInMinorUnits` is ignored when value is a bigint
* @default 0
* @example
* valueInMinorUnits=true: 1299 => "$12.99"
* valueInMinorUnits=false: 12.99 => "$12.99"
+ * bigint: 1299n => "$1,299.00" (always major units)
*/
- value: number | string;
+ value: number | string | bigint;
/**
* ISO 4217 currency code
@@ -66,6 +70,16 @@ export interface AmountProps extends ComponentProps<'span'> {
* @default true
*/
groupDigits?: boolean;
+
+ /**
+ * Render the formatted number without a currency symbol, code, or name.
+ * Locale-driven separators, grouping, and fraction digits are preserved.
+ * When true, `currencyDisplay` is ignored.
+ * @default false
+ * @example
+ * => "12.99"
+ */
+ hideCurrency?: boolean;
}
/**
@@ -151,6 +165,7 @@ export const Amount = ({
maximumFractionDigits,
groupDigits = true,
valueInMinorUnits = true,
+ hideCurrency = false,
...props
}: AmountProps) => {
try {
@@ -160,7 +175,7 @@ export const Amount = ({
) {
console.warn(
`Warning: The number ${value} exceeds JavaScript's safe integer limit (${Number.MAX_SAFE_INTEGER}). ` +
- 'For large numbers, pass the value as a string to maintain precision.'
+ 'For large numbers, pass the value as a bigint or string to maintain precision.'
);
}
@@ -171,37 +186,100 @@ export const Amount = ({
const decimals = getCurrencyDecimals(validCurrency);
- // Handle minor units - use string manipulation for strings and Math.pow for numbers
- const baseValue =
- valueInMinorUnits && decimals > 0
- ? typeof value === 'string'
- ? value.slice(0, -decimals) + '.' + value.slice(-decimals)
- : value / Math.pow(10, decimals)
- : value;
-
- // Remove decimals if hideDecimals is true - handle string and number separately
- // Note: Not all numbers passed is converted to string as methods like Math.trunc
- // or toString cannot handle large numbers thus, we need to handle it separately (large numbers passed in value throws console warning).
- const finalBaseValue = hideDecimals
- ? typeof baseValue === 'string'
- ? baseValue.split('.')[0]
- : Math.trunc(baseValue)
- : baseValue;
-
- const formattedValue = new Intl.NumberFormat(locale, {
- style: 'currency' as const,
+ /**
+ * Convert minor → major units.
+ * Three input shapes: bigint, string, number.
+ * BigInt is always treated as already in major units (it cannot represent fractions),
+ * so `valueInMinorUnits` is ignored for BigInt.
+ */
+ let baseValue: number | string | bigint;
+ if (typeof value === 'bigint') {
+ baseValue = value;
+ } else if (valueInMinorUnits && decimals > 0) {
+ if (typeof value === 'string') {
+ const isNegative = value.startsWith('-');
+ const digits = isNegative ? value.slice(1) : value;
+ const padded = digits.padStart(decimals + 1, '0');
+ const major = padded.slice(0, -decimals);
+ const minor = padded.slice(-decimals);
+ baseValue = `${isNegative ? '-' : ''}${major}.${minor}`;
+ } else {
+ baseValue = value / Math.pow(10, decimals);
+ }
+ } else {
+ baseValue = value;
+ }
+
+ // Remove decimals when hideDecimals is true. BigInt has no decimals, so it's a no-op there.
+ const finalBaseValue: number | string | bigint = !hideDecimals
+ ? baseValue
+ : typeof baseValue === 'bigint'
+ ? baseValue
+ : typeof baseValue === 'string'
+ ? baseValue.split('.')[0]
+ : Math.trunc(baseValue);
+
+ /**
+ * Always format in currency mode — Intl's currency-style handles fraction digits per the currency,
+ * locale-correct grouping/separators,
+ * and auto-clamps when only one of min/max is user-provided.
+ * For hideCurrency, we then strip the currency token from the output via formatToParts(),
+ * which avoids the divergent defaults of style: 'decimal'.
+ */
+ const formatOptions: Intl.NumberFormatOptions = {
+ style: 'currency',
currency: validCurrency.toUpperCase(),
currencyDisplay,
minimumFractionDigits: hideDecimals ? 0 : minimumFractionDigits,
maximumFractionDigits: hideDecimals ? 0 : maximumFractionDigits,
useGrouping: groupDigits
- // @ts-expect-error TS lib types omit `string` from format() params, but Intl.NumberFormat accepts numeric strings at runtime — needed for large values that would lose precision as `number`.
- } as Intl.NumberFormatOptions).format(finalBaseValue);
+ };
+
+ /**
+ * Only flag strings whose digit count exceeds 15 — Number.MAX_SAFE_INTEGER
+ * is 9,007,199,254,740,991 (16 digits), so ≤15-digit strings round-trip
+ * through Number() without precision loss even on non-V3 runtimes.
+ */
+ if (
+ typeof finalBaseValue === 'string' &&
+ finalBaseValue.replace(/\D/g, '').length > 15
+ ) {
+ console.warn(
+ 'Amount: a string longer than 15 digits is being formatted. ' +
+ 'Older runtimes without Intl.NumberFormat V3 string-precision support ' +
+ '(Chrome <106, Firefox <116, Safari <15.4, Node <19) coerce strings via ' +
+ 'Number() first, which loses precision beyond 2^53. Pass a bigint for ' +
+ 'exact integer formatting if your targets include those runtimes.'
+ );
+ }
+
+ const formatter = new Intl.NumberFormat(locale, formatOptions);
+
+ /**
+ * For hideCurrency, strip the `currency` parts and trim leading/trailing
+ * whitespace that locales like de-DE leave behind
+ * (e.g. "1.234,56 €" becomes "1.234,56 " before the trim).
+ * Otherwise format directly.
+ */
+ const formattedValue: string = hideCurrency
+ ? formatter
+ .formatToParts(
+ // @ts-expect-error TS lib types omit `string` from formatToParts() params, but Intl accepts numeric strings at runtime.
+ finalBaseValue
+ )
+ .filter(p => p.type !== 'currency')
+ .map(p => p.value)
+ .join('')
+ .trim()
+ : formatter.format(
+ // @ts-expect-error TS lib types omit `string` from format() params, but Intl.NumberFormat accepts numeric strings at runtime — needed for large values that would lose precision as `number`.
+ finalBaseValue
+ );
return {formattedValue};
} catch (error) {
console.error('Error formatting amount:', error);
- return {value};
+ return {String(value)};
}
};