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.


Permalink to "Related"

Back to Assistive Technology Behaviour Differences