Formatting Numbers and Units in Table Cells for Screen Readers

Permalink to "Formatting Numbers and Units in Table Cells for Screen Readers"

A number in a table cell is read by a speech engine that knows nothing about finance, telemetry or your design system’s conventions. (1,280) is spoken as “left paren one thousand two hundred eighty right paren” by some voices and as “1280” by others, and neither tells the listener it is a loss. A red -4.2% loses its colour entirely. A cell showing — is announced as “em dash”, or as nothing.

This page covers the formatting decisions that decide whether a numeric column is understandable by ear: where units go, how to write negatives, how to expand abbreviations, and what to put in an empty cell. It is part of semantic HTML table construction, and the header wiring it relies on is described in scope and headers in complex tables.

Spec reference

Permalink to "Spec reference"

There is no ARIA attribute for “this is a number”. The relevant requirements come from WCAG:

  • SC 1.3.1 Info and Relationships (A) — meaning conveyed by presentation, such as a red colour or parentheses meaning “negative”, must also be available in text or structure.
  • SC 1.4.1 Use of Color (A) — colour cannot be the only visual means of conveying information. A red number with no sign fails.
  • SC 3.1.4 Abbreviations (AAA) — a mechanism for identifying the expanded form of abbreviations. Not required at AA, but 1.2k and 3.4M in a financial table are the textbook case.

The HTML <data value=""> element carries a machine-readable value alongside the human-readable text, which is useful for sorting and copying, but screen readers read the element’s text content, not its value. The <abbr title=""> element exposes its title as a description in some browsers and not others, so it is a supplement rather than a guarantee.

What the listener hears Comparison of numeric cell formats that speak ambiguously against formats that speak clearly. What the listener hears✗ Ambiguous by ear(1,280) — "left paren 1280 right paren", or just"1280"-4.2% in red — the sign may be read as "dash"or dropped1.2k — "1.2 k"— in an empty cell — "em dash" or silence$ on every cell — repeated forty times percolumn✓ Clear by ear−1,280 with a real minus sign (U+2212) —"minus 1280"▼ 4.2% plus a hidden "down" — "down 4.2percent"1.2k with a hidden "1,200" — "1200""No data" or "Not applicable" — spoken aswords
The formats on the left look conventional and speak badly; the right-hand forms cost almost nothing visually.

When to use each technique — and when not to

Permalink to "When to use each technique — and when not to"

Move a shared unit to the header whenever every cell in the column uses it. “Amount (EUR)” in the <th> is announced once when the user enters the column and again with each cell only if they ask for header repetition. Repeating € in forty cells adds noise and, in some voices, reads as “euro” forty times.

Keep the unit in the cell when it varies row by row — a mixed-currency ledger, or a telemetry table where one row is milliseconds and the next is seconds. Consistency matters more than brevity there.

The common misapplication is hiding the whole number behind aria-label on the cell to “fix” pronunciation. aria-label on a <td> is not reliably exposed — the cell role does not support naming from author in every browser, and NVDA in browse mode reads the content, not the label. Use visually hidden text inside the cell instead; it is read by every reader because it is ordinary content.

A numeric column that speaks correctly Mock table of quarterly results with the unit in the header, a signed negative value, an expanded abbreviation and an explicit not-applicable cell, each numbered and explained. A numeric column that speaks correctlyRegionRevenue (EUR)1ChangeTargetNorth1,280,000▲ 4.2%1.2M3South940,000▼ 1.1%21.0MEast0Not applicable4—1Unit lives in the header, announced onceper column2Arrow plus hidden "down": the sign neverrelies on colour3Abbreviation carries hidden full value"1,200,000"4Missing data is written as words, not leftblank
Each fix is invisible or nearly invisible to sighted users — the cost of doing this is close to zero.

Annotated code example

Permalink to "Annotated code example"
<table>
  <caption>Quarterly revenue by region</caption>
  <thead>
    <tr>
      <th scope="col">Region</th>
      <!-- SC 1.3.1: shared unit in the header, not repeated per cell -->
      <th scope="col">Revenue (EUR)</th>
      <th scope="col">Change on last quarter</th>
      <th scope="col">Target</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">South</th>
      <!-- data value keeps copy and sort exact; text is what gets read -->
      <td><data value="940000">940,000</data></td>
      <td class="delta delta--down">
        <!-- SC 1.4.1: arrow and word carry the direction, colour is extra -->
        <span aria-hidden="true">▼</span>
        <span class="visually-hidden">down</span> 1.1%
      </td>
      <td>
        <!-- SC 3.1.4 (AAA): expanded magnitude for speech -->
        <span aria-hidden="true">1.0M</span>
        <span class="visually-hidden">1,000,000</span>
      </td>
    </tr>
    <tr>
      <th scope="row">East</th>
      <td><data value="0">0</data></td>
      <!-- SC 1.3.1: absence stated in words, never an empty cell -->
      <td>Not applicable</td>
      <td><span aria-hidden="true">—</span><span class="visually-hidden">No target</span></td>
    </tr>
  </tbody>
</table>

For negatives, prefer the Unicode minus sign − (U+2212) over the hyphen-minus. Both NVDA and JAWS with their default symbol dictionaries speak U+2212 as “minus”; the hyphen-minus before a digit is spoken as “minus” by some voices and “dash” by others depending on punctuation level. If your formatter comes from Intl.NumberFormat, set signDisplay: 'exceptZero' and replace the hyphen afterwards:

// Consistent signed output for speech: "−1,280" and "+4.2%"
const fmt = new Intl.NumberFormat('en-GB', { signDisplay: 'exceptZero' });
const signed = (n) => fmt.format(n).replace('-', '−'); // SC 1.3.1

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Cell content NVDA (default punctuation) JAWS VoiceOver
−1,280 (U+2212) “minus 1280” “minus 1280” “minus 1,280”
-1,280 (hyphen) “minus 1280” “dash 1280” at some levels “negative 1,280”
(1,280) “1280” “left paren 1280 right paren” “1,280”
1.2k with hidden full value “1200” “1200” “1,200”
Empty <td> “blank” “blank” silence, then next cell

The table shows why the parenthesis convention is the worst option: two of three readers drop the only signal that the value is negative. Accounting teams will ask for parentheses; give them the parentheses visually and a minus sign in the hidden text.

Characters spoken per cell, repeated unit versus header unit Bar chart comparing the spoken length of a 40-row currency column when the currency symbol is repeated in every cell against placing it once in the header. Characters spoken per cell, repeated unit versus header unitUnit in every cell160 words — 40 rows × "euro" + numberUnit in header only124 words — 40 numbers + one headerHeader, abbreviated cells84 words — shorter, but needs hidden expansions
Moving the unit to the header removes about a fifth of the speech in a typical currency column.

Integration context

Permalink to "Integration context"

Formatting decisions interact with sorting and copying. A sortable column should sort on the <data value> or an equivalent data attribute, not on the formatted text — otherwise “−1,280” sorts as a string. The sort state itself is covered in aria-sort attributes for accessible column filtering.

Totals rows raise the stakes because a user who reads only the total needs the sign and unit to be unambiguous. The patterns in announcing totals and summary rows assume the cell formatting on this page.

Gotchas

Permalink to "Gotchas"

Locale-dependent separators. 1.280 is one thousand two hundred and eighty in German and one point two eight in English. Speech engines follow the document language, so set lang on the page correctly and format with the same locale. A German-formatted number on an lang="en" page is misread.

Hidden text breaking copy-paste. Visually hidden spans are copied along with the visible text, so a user copying “1.0M” gets “1.0M1,000,000”. If copy fidelity matters, add a copy handler that writes the <data value> to the clipboard, as in keyboard copy and paste in data grids.

Tabular figures and alignment. Right-aligning numbers with font-variant-numeric: tabular-nums does not affect speech at all, but it matters for low-vision users scanning a column at high zoom. It is a free improvement.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
Should currency symbols be in every cell or only in the header?

In the header when every cell in the column uses the same currency — “Amount (EUR)” — so it is announced once instead of on every row. Keep the symbol in each cell only when currencies are mixed within the column.

Should I use aria-label to fix how a number is pronounced?

No. aria-label on a table cell is not consistently exposed across browsers and screen readers, and in browse mode the cell content is read instead. Put the spoken form in visually hidden text inside the cell, and mark the visual form aria-hidden when the two differ.

How should negative numbers be written in an accessible table?

Use a real sign — the Unicode minus sign U+2212 is the most consistently spoken — or a direction word in hidden text. Never rely on red colour or parentheses alone, because colour is not announced and parentheses are dropped by some readers.

What should an empty cell contain?

Words that state why it is empty, such as “No data”, “Not applicable” or “Pending”. An empty td is announced as “blank” by NVDA and JAWS and skipped silently by VoiceOver, which the user cannot distinguish from a rendering failure.

Permalink to "Related"

← Back to Semantic HTML Table Construction