web3icons

Formatting token amounts and decimals

How to turn raw token integers into readable amounts, pick decimals and significant digits, format per locale, add fiat values and never hide a balance.

5 min read

A token balance arrives from the chain as a large integer with no decimal point. Turning that into something like "1,250.5 USDC" involves three separate decisions: converting the integer using the token's decimals, choosing how many digits to show in each context, and formatting for the reader's locale. Get any of them wrong and people see balances that are misleading, or that make their next transaction fail.

This guide works through each step with code that uses only BigInt and the built-in Intl.NumberFormat.

Raw amounts and decimals

ERC-20 contracts store balances as integers. The contract's decimals() value says where the decimal point goes. EIP-20 makes decimals optional, and in practice the value varies:

Token Decimals on Ethereum 1 token as a raw integer
Many ERC-20 tokens 18 1000000000000000000
USDC, USDT 6 1000000
WBTC 8 100000000

Never assume 18. Read decimals() from the contract, or from a token list you trust, and store it with the token. The same asset can also use different decimals on different chains, since each deployment is its own contract. Read the value per chain, not per ticker.

Raw amounts exceed the range JavaScript numbers can represent exactly, so convert with BigInt:

function toDecimalString(raw: bigint, decimals: number): string {
  const negative = raw < 0n
  const abs = negative ? -raw : raw
  const base = 10n ** BigInt(decimals)
  const whole = abs / base
  const fraction = (abs % base).toString().padStart(decimals, '0').replace(/0+$/, '')
  const text = fraction ? `${whole}.${fraction}` : whole.toString()
  return negative ? `-${text}` : text
}

toDecimalString(1500000n, 6) // '1.5'
toDecimalString(1234567890000000000n, 18) // '1.23456789'

Client libraries such as viem and ethers include an equivalent formatUnits function. Whichever you use, keep the exact decimal string for calculations and inputs, and treat formatted text as display only.

How many digits to show

There is no single right precision. The right amount depends on what the person is doing with the number.

Context Rule of thumb Example
Balance list About 4 to 6 significant digits 1,250.5 · 0.04213
Amount input Exactly what the person typed 0.0425
"Max" button The full raw balance 0.042137715
Review screen Up to 6 to 8 decimals, plus fiat 0.04213771 ETH ($112.40)
Detail or tooltip Full precision 0.042137715309481002
Fiat values 2 decimals $1,250.50

Significant digits work better than a fixed number of decimals for lists, because token prices range across many orders of magnitude. Four decimals is too few for a token worth thousands per unit and pointlessly many for one worth a fraction of a cent.

Round in the safe direction

Default number formatting rounds half up. For balances, that can show more than the person owns. A balance of 9.999996 USDC rounded to two decimals displays as "10.00". If they then type 10 and press Send, the transaction fails with an insufficient balance error.

Truncate balances instead, so the displayed number is never larger than the real one. Intl.NumberFormat supports a roundingMode option, and in current engines its format method accepts a decimal string, which avoids converting through a floating-point number:

function formatBalance(value: string, locale?: string): string {
  const n = Number(value)
  if (n === 0) return '0'
  if (n < 0.0001) {
    return `<${new Intl.NumberFormat(locale).format(0.0001)}`
  }
  return new Intl.NumberFormat(locale, {
    maximumSignificantDigits: 6,
    roundingMode: 'trunc',
  }).format(value as unknown as number)
}

formatBalance('9.999996') // '9.99999'
formatBalance('0.000004') // '<0.0001'
formatBalance('1250.5', 'de-DE') // '1.250,5'

The cast is only for TypeScript, whose types may not list string arguments depending on your version and lib setting. roundingMode and string input are recent additions to Intl; if you support older browsers, check their compatibility tables and test what happens without them (older engines convert the string to a number and ignore the option).

The other half of this rule: never round a non-zero balance to zero. "0" tells the person their wallet is empty. <0.0001 tells them something is there, too small to show. The same applies to fiat: show <$0.01 for dust, not $0.00.

And the "Max" button should fill the input from the raw balance, not from the formatted text. If a person clicks Max on a displayed "9.99999", the transaction should spend 9.999996, otherwise a remainder is left behind that they can't easily clear.

Very large and very small numbers

Large numbers read best in compact notation when space is tight:

new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(1_234_567)
// '1.2M'

Compact notation is localized too, so "1.2M" in English becomes a different suffix in other locales. Use it for market caps, volumes and supply figures. For a person's own balance, prefer the full number with grouping separators unless it truly doesn't fit, since people want to see exactly what they hold.

For very small prices, some interfaces collapse leading zeros, writing 0.00000412 with a subscript count of the zeros. That saves space but many readers won't recognize it, and screen readers won't read it the way it looks. If you use it, offer the full value on hover and focus, and give the element an accessible label with the plain number. Making token amounts and prices accessible goes into how screen readers handle abbreviations and symbols.

Locale formatting

Decimal and grouping separators differ by locale. The same number is 1,250.5 in US English, 1.250,5 in German and 1 250,5 in French, where the grouping separator is a narrow no-break space. Pass the user's locale, or undefined to use the browser's, and let Intl handle it.

Inputs are harder than display. Use a text input with inputmode="decimal", which shows a numeric keypad on most phones, rather than type="number", which has inconsistent behavior with separators and very small values. Accept the locale's decimal separator, convert it to a period before parsing, and reject more decimals than the token supports.

Keep the ticker next to every amount, after the number, in the same position everywhere: "1,250.5 USDC". An amount without its unit is ambiguous in an app with dozens of assets.

Fiat equivalents

A fiat value helps people judge an amount, but it's an estimate built on a price that may be seconds or minutes old. Mark it as approximate ("≈ $112.40"), keep it visually secondary to the token amount, and refresh it on a sensible interval.

When there is no price for a token, show nothing in the fiat column rather than "$0.00". A zero reads as "worthless", which may be wrong, and it skews totals. Mark totals that exclude unpriced tokens. The same rules extend to dashboards with many rows, which designing crypto dashboards that stay readable covers.

Checklist

  • Read decimals from each token contract on each chain.
  • Convert with BigInt, keep the exact string for calculations.
  • Pick precision per context, with significant digits for lists.
  • Truncate balances, never round up, never show a non-zero balance as 0.
  • Fill Max from the raw value.
  • Format with Intl.NumberFormat and the reader's locale.
  • Show fiat as approximate, and blank when no price exists.

More in Web3 UX

All guides