NVDA vs JAWS: aria-sort Announcement Differences
Permalink to "NVDA vs JAWS: aria-sort Announcement Differences"aria-sort tells assistive technology which direction a column is sorted, but NVDA and JAWS — both Windows screen readers, often driving the same page — do not announce it the same way. NVDA reads the direction from the column header and speaks it on focus; JAWS tends to derive the header’s state from the accessible name of the control inside it. The single failure this page prevents: shipping a sortable grid that announces “sorted ascending” in NVDA and only “Status column” in JAWS, leaving JAWS users unable to tell the current sort direction.
This is a focused companion to the aria-sort attributes for accessible column filtering reference and to the broader survey of assistive technology behaviour differences.
Spec reference
Permalink to "Spec reference"aria-sort is a property defined in the ARIA specification. It is valid only on elements with the columnheader or rowheader role — which <th scope="col"> and <th scope="row"> satisfy natively. Its tokens are:
| Value | Meaning | Default |
|---|---|---|
none |
Sortable but currently unsorted | Implicit default for a sortable header |
ascending |
Sorted low-to-high, A-to-Z, oldest-first | — |
descending |
Sorted high-to-low, Z-to-A, newest-first | — |
other |
Sorted by a non-directional custom algorithm | — |
Default behaviour: a header with no aria-sort is not conveyed as sortable at all. On Windows, the browser carries the aria-sort value into the header node’s IA2 object attributes (as a sort key), and screen readers decide independently whether and how to read it. That decision is where NVDA and JAWS diverge.
The governing success criteria are 4.1.2 Name, Role, Value (Level A) — the sort state is a “value” that must be programmatically determinable — and 4.1.3 Status Messages (Level AA) for the post-sort confirmation.
When to rely on aria-sort alone vs. when NOT to
Permalink to "When to rely on aria-sort alone vs. when NOT to"Rely on aria-sort alone only when your entire audience uses a reader that speaks it from IA2 — in practice, an NVDA-only environment. NVDA announces the direction on header focus directly from the object attribute, so no duplication is strictly required for NVDA users.
Do NOT rely on aria-sort alone in any mixed environment that includes JAWS. Because JAWS commonly reports the header’s state from the accessible name of the button inside it rather than the raw token, a header that carries only aria-sort="ascending" — with a button labelled just “Status” — can announce no direction at all in JAWS. The misuse to name explicitly: treating aria-sort as sufficient on its own. It is necessary but, for JAWS, not sufficient. Always mirror the direction into the accessible name.
Annotated code example
Permalink to "Annotated code example"The reliable pattern encodes direction in two places at once: the aria-sort attribute (which NVDA reads) and the button’s accessible name (which JAWS reads). Update both before the DOM reorder, then confirm through a live region.
<!-- Sortable header: aria-sort on the th, direction mirrored in the button name -->
<!-- aria-sort → SC 4.1.2 Name, Role, Value (sort state as a value) -->
<th scope="col" aria-sort="ascending" id="col-status">
<!-- The button carries the accessible name JAWS reads for state -->
<button type="button" class="sort-trigger" aria-label="Status, sorted ascending">
Status
<!-- Decorative icon hidden from AT — SC 1.1.1 Non-text Content -->
<span class="sort-icon" aria-hidden="true">▲</span>
</button>
</th>
<!-- Unsorted sibling: aria-sort="none" so readers can distinguish
"sortable, not sorted" from "not sortable" -->
<th scope="col" aria-sort="none" id="col-name">
<button type="button" class="sort-trigger" aria-label="Name, not sorted">
Name <span class="sort-icon" aria-hidden="true">⇅</span>
</button>
</th>
// Apply sort state BEFORE reordering rows so NVDA reads the new tree,
// and mirror the direction into the accessible name so JAWS speaks it.
function applySort(header, direction) { // direction: 'ascending' | 'descending' | 'none'
const focusRef = document.activeElement; // preserve focus across the mutation
// 1. Reset every sortable header to none, then set the active one.
document.querySelectorAll('th[aria-sort]').forEach((th) => {
th.setAttribute('aria-sort', 'none'); // SC 4.1.2 — state exposed to NVDA via IA2
const btn = th.querySelector('.sort-trigger');
const name = btn.textContent.replace(/[▲▼⇅]/g, '').trim();
btn.setAttribute('aria-label', `${name}, not sorted`); // SC 4.1.2 — name JAWS reads
});
// 2. Activate the target header — attribute AND name, before the reorder.
header.setAttribute('aria-sort', direction);
const activeBtn = header.querySelector('.sort-trigger');
const activeName = activeBtn.textContent.replace(/[▲▼⇅]/g, '').trim();
activeBtn.setAttribute('aria-label', `${activeName}, sorted ${direction}`);
// 3. Now reorder the DOM (rows sorted elsewhere), then restore focus.
reorderRows(header.cellIndex, direction);
if (focusRef && focusRef.isConnected) focusRef.focus();
// 4. Confirm via live region so buffer-timing gaps never leave a reader silent.
announce(`Table sorted by ${activeName} ${direction}`); // SC 4.1.3 Status Messages
}
The order in steps 2 and 3 is load-bearing: NVDA reads the accessibility tree at the instant the DOM mutation is observed, so the attribute must already hold the new value when rows reorder.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Interaction | NVDA announcement | JAWS announcement | Deviation & workaround |
|---|---|---|---|
| Focus a sortable-but-unsorted header | “Name, not sorted, column header, button” | “Name, column header, button” | JAWS may omit “not sorted” if it is only in aria-sort; keep it in the button name |
| Focus a header sorted ascending | “Status, sorted ascending, column header” (direction from IA2) | “Status, sorted ascending, button” (direction from name) | With direction only in aria-sort, JAWS may say just “Status” — mirror it into the name |
Activate sort with Enter/Space |
Re-announces header with new direction, then live-region string | Announces new accessible name, then live-region string | If you reorder before setting the attribute, NVDA speaks the old direction |
| Navigate away and back to the header | Reads current aria-sort from the buffer |
Reads current accessible name | After a buffer-stale update, refresh with NVDA+F5 to re-sync |
| Rapid repeated sorts | Live-region merges to final value | Live-region queues; may lag | Debounce announcements so only the settled state is spoken |
Integration context
Permalink to "Integration context"This page is the reader-specific counterpart to the attribute-level rules in aria-sort attributes for accessible column filtering, which specifies valid hosts and timing; here we focus on how the two Windows readers differ once those rules are followed. The live-region confirmation in step 4 uses the politeness rules from choosing between polite and assertive aria-live regions. For the wider explanation of why IA2-based and name-based read paths exist at all, see the parent survey of assistive technology behaviour differences.
Gotchas
Permalink to "Gotchas"1. Direction only in aria-sort
Permalink to "1. Direction only in aria-sort" The most common JAWS-silent bug: aria-sort="descending" on the header, but the button is labelled just “Status”. NVDA speaks “sorted descending”; JAWS speaks “Status” with no direction. Always mirror the direction into the button’s accessible name.
2. Attribute set after the reorder
Permalink to "2. Attribute set after the reorder"If the sort handler reorders rows first and sets aria-sort afterwards, NVDA — reading the tree at mutation time — can announce the previous direction. Set the attribute and the name, then mutate the DOM.
3. aria-sort on the wrong element
Permalink to "3. aria-sort on the wrong element" Placing aria-sort on the <td>, on the <button>, or on a wrapping <div> makes it invalid; neither reader reads it. It belongs on the <th> (or a role="columnheader"/rowheader element).
FAQ
Permalink to "FAQ"Why does NVDA announce aria-sort but JAWS stays silent on the same header?
NVDA reads the aria-sort value from the IA2 object attributes on the column header and speaks the direction on focus. JAWS historically derives the header’s spoken state from the accessible name of the control inside it rather than the raw aria-sort token, so if the direction is not in the button’s accessible name JAWS may announce only the header text. Encoding the direction in both aria-sort and the button’s aria-label makes both readers consistent.
Does the order of updating aria-sort and reordering rows matter?
Yes. Set aria-sort on the header before you reorder the rows. NVDA reads the accessibility tree at the moment the mutation is observed, so if you reorder first and set the attribute afterwards, NVDA can announce the previous sort state. Update the attribute and the accessible name, then mutate the DOM, then restore focus and announce through a live region.
Should I put aria-sort on the th or on the sort button?
Put aria-sort on the <th> (or an element with role columnheader or rowheader), never on the button. aria-sort is only valid on a sortable header, and both NVDA and JAWS look for it there. The button carries the accessible name and the click handler; the header carries the sort state.
Related
Permalink to "Related"- aria-sort attributes for accessible column filtering — valid hosts, token semantics, and attribute timing rules
- Assistive technology behaviour differences — why IA2 and accessible-name read paths diverge across readers
- Choosing between polite and assertive aria-live regions — politeness for the post-sort confirmation