web3icons

Making token amounts and prices accessible

How to format token amounts, compact numbers, tickers and price changes so they read well aloud, and how to handle live prices without flooding users.

5 min read

Numbers are the main content of a wallet, an exchange or a portfolio page, and every formatting choice you make for sighted readers also changes what a screen reader says. "1.2K" might be read as "one point two K". A tiny balance shown as "<0.0001 ETH" might lose its "<" entirely and come out as a different number. A price column that updates every second can drown out everything else on the page.

Most of these problems come down to a handful of decisions about formatting, symbols and live updates.

Let Intl do the formatting

Build every visible number with Intl.NumberFormat and the user's locale, rather than concatenating strings. The same balance is 1,234.5 in the US and 1.234,5 in Germany. A screen reader reads numbers by the rules of the language it is speaking, so an English voice reads 1.234 as "one point two three four". Separators in the wrong place change the value people hear.

const locale = navigator.language

const usd = new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD' })
const amount = new Intl.NumberFormat(locale, { maximumFractionDigits: 4 })

usd.format(3120.45) // "$3,120.45" in en-US
amount.format(1234.56789) // "1,234.5679" in en-US

Currency style works for fiat because USD and EUR are ISO 4217 codes. Tokens are not. Intl accepts any three-letter code, so currency: 'ETH' happens to produce "ETH 1.50", but a four-letter ticker like USDC throws a RangeError. Format the number on its own and append the ticker: "1.5 ETH", "250 USDC".

Keep precision sensible as well. A balance printed with 18 decimal places is read digit by digit after the decimal point, so 18 digits become a very long sentence. A common approach is four to six decimals for volatile tokens and two for stablecoins. Formatting token amounts and decimals covers precision and rounding in detail, including why raw values should stay as bigints or decimal strings.

Compact numbers have two readings

Market caps and volumes are usually abbreviated. Intl can produce both the short form for the screen and a spelled-out form for assistive technology:

const short = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 })
const long = new Intl.NumberFormat('en-US', {
  notation: 'compact',
  compactDisplay: 'long',
  maximumFractionDigits: 1,
})
Value short long
1,234 1.2K 1.2 thousand
3,400,000 3.4M 3.4 million
1,200,000,000,000 1.2T 1.2 trillion

How a screen reader reads "3.4M" depends on the product and its settings. It may say "three point four million", or "three point four M", which listeners then have to decode. The long form removes the guesswork:

function CompactUsd({ value }: { value: number }) {
  return (
    <>
      <span aria-hidden="true">${short.format(value)}</span>
      <span className="sr-only">{long.format(value)} US dollars</span>
    </>
  )
}

The $ before the expression is plain JSX text, so sighted users see "$3.4M" and screen reader users hear "3.4 million US dollars". In a dense table, consider whether the abbreviation is needed at all. Wider columns with full numbers are sometimes the simpler fix.

Symbols that disappear

Many screen readers skip some punctuation and symbols at default verbosity settings. Which ones depends on the product and the user's settings, so the safe assumption is that a symbol might not be spoken. That matters when the symbol changes the meaning:

  • < 0.0001 ETH heard as "0.0001 ETH" is wrong by orders of magnitude.
  • ≈ $12.40 loses the information that it is an estimate.
  • -1.1% may come out as "1.1 percent", with the hyphen-minus silently dropped.
  • ▲ and ▼ may be read as long Unicode names, such as "black up-pointing triangle".

The fix is the same each time: keep the symbol on screen and give assistive technology words.

const pct = new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 2 })

function Change({ value }: { value: number }) {
  const direction = value > 0 ? 'up' : value < 0 ? 'down' : 'unchanged'
  const arrow = value > 0 ? '▲' : value < 0 ? '▼' : '–'
  const text = pct.format(Math.abs(value))
  return (
    <span data-direction={direction}>
      <span aria-hidden="true">
        {arrow} {text}
      </span>
      <span className="sr-only">
        {direction} {text}
      </span>
    </span>
  )
}

// <Change value={0.024} />  shows "▲ 2.4%", reads "up 2.4%"

The percent style multiplies by 100, so pass fractions: 0.024 becomes "2.4%". For tiny balances, show "< 0.0001 ETH" on screen and put "less than 0.0001 ETH" in the hidden text. For estimates, write "about" in the hidden text.

Color says direction only to some people

Green for up and red for down fails for people who can't tell those colors apart, and it isn't even a universal convention: in some markets, including mainland China, red means a price went up. 1.4.1 Use of Color (A) requires another cue, and the arrow or sign in the component above provides one.

The colors still need to meet 1.4.3 Contrast (Minimum) (AA) as text, which means 4.5:1 for normal-size text. Common choices don't always survive a theme switch. Red #dc2626 reaches 4.83:1 on white but only 3.88:1 on a #121212 dark background, while #f87171 reaches 6.77:1 on that same dark background. A green like #16a34a is 3.30:1 on white and fails for normal-size text, where #15803d reaches 5.02:1. Check both themes. Don't rely on color alone has more patterns for crypto interfaces.

Tickers and units

Always put the unit next to the number. "1.5" in a balance column means nothing when heard on its own, and screen reader users often move through a table cell by cell, without the column header in mind.

Tickers are pronounced unpredictably. Depending on the screen reader and voice, ETH may be read as a word and USDC spelled out letter by letter, or the other way around for other tickers. That's usually fine, since people who hold a token learn how their screen reader says it. Where confusion is likely, such as two tokens with similar tickers in one list, show the token name too: "Ethereum, 1.5 ETH". A row label like this also gives the logo something to sit next to, so the logo itself can have empty alt text.

Live prices that don't take over

Prices on market pages may update every second. Two rules keep that manageable.

Don't put frequently updating numbers in a live region. With aria-live on a price table, every tick is queued for speech, and the user can no longer hear anything else. Update the numbers silently in place. Someone who moves to a cell hears its current value.

Give people a way to pause. 2.2.2 Pause, Stop, Hide (A) covers information that updates automatically and is presented alongside other content: there needs to be a way to pause, stop or hide it, or to control how often it updates, unless the updating is essential. A "Pause live prices" toggle or an update-interval setting covers it. When updates are visual, use font-variant-numeric: tabular-nums so the digits don't shift sideways, and skip flashing backgrounds for people who prefer less motion, as described in respecting prefers-reduced-motion.

There is one place where an update should be announced: a swap or order form where the quote refreshes while someone is reviewing it. If the amount they will receive changes, announce it once, politely, with the new figure ("Quote updated. You receive about 3,118.20 USDC."), and announce it again if the change pushes the trade past their slippage limit. Prices elsewhere on the page can stay quiet.

More in Accessibility

All guides