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.2kand3.4Min 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.
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.
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.
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.
Related
Permalink to "Related"- Table captions & summaries — where table-wide units can be explained
- Announcing totals & summary rows — the cells where formatting matters most
- Chart colour & contrast — the same “not colour alone” rule for charts