Announcing Cell Context on Navigation

Permalink to "Announcing Cell Context on Navigation"

When a screen reader user moves from one cell to the next, they hear some mix of value, column header, row header, position and state. Too little, and they lose track of which record they are in. Too much, and every move takes three seconds. Screen readers already announce changed headers when markup is right; most problems come from missing row headers, aria-label overrides on cells and live regions that duplicate what the screen reader says anyway.

This guide belongs to screen reader announcement strategies.

Spec reference

Permalink to "Spec reference"
  • SC 1.3.1 (A) — header relationships programmatic (th, scope, columnheader, rowheader).
  • ARIA grid — rowheader and columnheader roles; aria-describedby for extra cell information.
  • Screen reader behaviour — NVDA and JAWS announce headers that changed since the last cell; VoiceOver announces headers depending on settings.
What a screen reader assembles for a cell Flow: the user moves right into the Amount column; the screen reader notes the column header changed and reads it; the row header has not changed and is skipped; then the value and any description. What a screen reader assembles for a cellMove rightfrom Status to AmountColumn header changed"Amount"Row header unchangedskippedValue"1,240.00"Description"Overdue by 12 days"
Changed context first, then the value, then extras.

When to use — and when not to

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

Use native header semantics for every grid and table. Add descriptions only for important state that is not in the cell text: validation errors, edited-but-unsaved values, computed warnings.

Do not set aria-label on cells to build custom announcements like “Row 3, Amount, 1,240”; it replaces the screen reader’s own context handling and repeats information. The misapplication: a live region that announces “Row 3 column 4” on every arrow key, doubling every announcement.

Custom announcements versus native context Comparison: aria-label overrides and live regions on navigation duplicate and fight screen reader context; native headers plus targeted descriptions produce concise announcements. Custom announcements versus native context✗ aria-label on every cellLive region on every moveContext repeated every timeState hidden in colour✓ Native header associationNo live region for navigationOnly changed headers announcedState in descriptions

Annotated code example

Permalink to "Annotated code example"
<div role="grid" aria-label="Invoices">
  <div role="row">
    <span role="columnheader">Invoice</span><span role="columnheader">Status</span><span role="columnheader">Amount</span>
  </div>
  <div role="row">
    <span role="rowheader">INV-1042</span>                                   <!-- row identifier -->
    <span role="gridcell">Overdue</span>
    <span role="gridcell" aria-describedby="d-1042-amt">1,240.00</span>
    <span id="d-1042-amt" hidden>Edited, not saved</span>                     <!-- extra state only -->
  </div>
</div>
<!-- Anti-pattern: overriding the screen reader's context -->
<span role="gridcell" aria-label="Row 3, column Amount, value 1,240.00">1,240.00</span>

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Movement NVDA JAWS VoiceOver
Right, same row “Amount, 1,240.00” “Amount, 1,240.00” “1,240.00, Amount”
Down, same column “INV-1043, 2,980.00” “INV-1043, 2,980.00” “2,980.00” (+ row header setting)
Into a cell with description Value, then “Edited, not saved” Same Description after pause
With aria-label override Label replaces context Same Same
Sources of cell context Matrix of context sources — column header, row header, position, description, live region — against when they are announced and whether to use them for navigation. Sources of cell contextAnnounced whenUse for navigationColumn headerOn column changeYesRow headerOn row changeYesPosition (row/col)On request or settingOnly via indexesDescriptionAfter valueOnly for stateLive regionEvery updateNo
Let headers and descriptions do the work.

Step-by-step implementation

Permalink to "Step-by-step implementation"
  1. Identify the row header. Choose the column that identifies a record (invoice number, name) and make it a th scope="row" or role="rowheader".
  2. Keep column headers simple. Short header text makes repeated announcements quicker; put help in descriptions on the header, not in its name.
  3. Remove overrides. Delete aria-label from cells unless the visible text is genuinely unhelpful (an icon), and then label only the content.
  4. Add descriptions for state. Use aria-describedby for errors, unsaved edits or warnings; keep them short.
  5. Listen and compare. Navigate the same path in NVDA, JAWS and VoiceOver, and adjust markup rather than adding announcements.

Integration context

Permalink to "Integration context"

Header markup is covered in correct usage of scope and headers in complex tables and multi-level headers in multi-level column headers with colspan. Positions in partial grids are handled by aria-rowindex and aria-colindex in practice. Column help descriptions follow aria-describedby for column help text. VoiceOver specifics are in VoiceOver strategies for announcing table updates.

Gotchas

Permalink to "Gotchas"

aria-activedescendant grids. With active descendant focus management, screen readers rely on the referenced cell’s role and headers; make sure the referenced element is the cell, not an inner span.

Interactive content in cells. A focusable button inside a cell is announced by its own name; include the row identifier in that name.

Long headers. A forty-character header repeated on every column move is tiring; abbreviate visibly or with abbr on th.

Edge cases

Permalink to "Edge cases"
  • Merged cells. Spanning cells announce headers for their first row and column; check that this matches what users expect.
  • Empty cells. Screen readers say “blank”; if empty means something (“not applicable”), put that text in the cell, visually hidden if necessary.
  • Icons as values. A status icon needs text: “Overdue”, not “image”.

Worked example

Permalink to "Worked example"

A CRM grid set aria-label on every cell to “Row N, Column Name, Value” and also announced moves in a live region. NVDA users heard each cell twice, with the position first, so moving across a row took several seconds per cell. The team removed the labels and the live region, made the customer name a row header, and added descriptions only for unsaved edits. Announcements shrank to “Phone, 555 0142” and users navigated twice as fast.

Common mistakes

Permalink to "Common mistakes"
  • aria-label on every cell.
  • No row headers.
  • Live regions for navigation.
  • Status shown only by colour.

Testing checklist

Permalink to "Testing checklist"

Testing it with a screen reader

Permalink to "Testing it with a screen reader"

In NVDA, move right across a row and listen: you should hear the column header and value each time, and nothing about the row. Move down a column and listen for the row header and value. Turn on the speech viewer to compare announcement lengths before and after changes. Repeat in JAWS with its table navigation keys, and in VoiceOver, where you may need to enable “Speak headers” in table settings. Note any differences in your screen reader test matrix.

Measuring announcement length

Permalink to "Measuring announcement length"

A simple metric helps teams keep announcements short: words spoken per cell move. Use NVDA’s speech viewer or a Guidepup script to capture the spoken text for a fixed path through the grid — say twenty moves — and count words. A good target for column moves is under six words, including header and value. Track the number in your test suite so a future change that adds labels or descriptions to every cell shows up as a regression.

Choosing the row header column

Permalink to "Choosing the row header column"

The best row header is short, unique and meaningful to users: an invoice number, a person’s name, a product code. Avoid long free-text columns such as descriptions, which make every vertical move slow. If no single column identifies a row, combine two in a visually hidden row header (“Lund & Co, March”), or reorder columns so the identifying column comes first. If the visual design puts a checkbox first, the checkbox cell can stay first while the identifying column is still marked as the row header.

Design system notes

Permalink to "Design system notes"

Have the grid component require a row header column and forbid cell-level aria-label except for icon content, with lint checks.

FAQ

Permalink to "FAQ"
Should I announce row and column numbers?

Screen readers can report them on request; do not add them to every cell.

Why do I hear headers twice?

Probably an aria-label or live region duplicating native context.

Can I customise what is read?

Through markup: headers, row headers and descriptions. Avoid overrides.

What about VoiceOver?

It reads headers depending on user settings; ensure semantics are correct and test.

When is aria-label on a cell acceptable?

For icon-only content, and then only to name the icon.

Do descriptions slow navigation?

Slightly; use them only for state that matters.

Permalink to "Related"

← Back to Screen Reader Announcement Strategies for Data Interfaces