aria-sort Attributes for Accessible Column Sorting
Permalink to "aria-sort Attributes for Accessible Column Sorting"aria-sort is the single ARIA attribute that communicates the current data-ordering direction of a column header to assistive technology. Its sole job is to reflect the visual sort state — it has nothing to do with filtering. Misplacing it or misusing it to signal filter activity violates WCAG 2.2 SC 4.1.2 (Name, Role, Value) and causes screen readers to announce incorrect information.
Spec reference
Permalink to "Spec reference"The attribute is defined in the ARIA 1.2 specification under the columnheader and rowheader roles. It is only valid on elements that carry one of those roles — typically <th scope="col"> in an HTML table or a <div role="columnheader"> in a composite grid widget.
| Value | Meaning |
|---|---|
ascending |
Column data is sorted low-to-high (A→Z, 1→9, oldest→newest) |
descending |
Column data is sorted high-to-low (Z→A, 9→1, newest→oldest) |
other |
A sort algorithm is active but is neither ascending nor descending |
none |
The column supports sorting but is not currently the sort key |
Default behaviour: if aria-sort is absent from a <th>, assistive technology cannot determine whether the column is sortable at all. Explicitly setting aria-sort="none" on every sortable column header is therefore preferable to omitting the attribute on inactive columns.
Only one column header should carry ascending or descending at any time. The moment a new column is sorted, every other sortable header must be reset to none.
When to use vs. when NOT to use
Permalink to "When to use vs. when NOT to use"Use aria-sort when:
- A
<th>(orrole="columnheader") controls the order of visible rows. - The sort is active and reflected in the rendered DOM order.
- You want screen readers to announce “sorted ascending” or “sorted descending” when users navigate to the header.
Do NOT use aria-sort when:
- The control is a filter input — filtering reduces dataset membership; it does not reorder rows. Use
aria-controlson the input instead. - The column contains no sortable data (e.g., a row-actions column with edit/delete buttons).
- The sort is pending but the DOM has not yet reordered — wait until the rows are in their new order before updating the attribute.
- You are trying to communicate loading state; use
aria-busy="true"on the grid container for that.
The most common violation in production grids: a developer places aria-sort directly on the sort <button> inside a <th>, rather than on the <th> itself. This breaks the attribute’s host-element contract and produces inconsistent announcements across NVDA, JAWS, and VoiceOver.
Structural diagram: sort vs. filter separation
Permalink to "Structural diagram: sort vs. filter separation"The diagram below shows the correct ownership model — aria-sort lives on the <th>, filter concerns live entirely in a separate element linked via aria-controls.
Annotated code example
Permalink to "Annotated code example"<!-- SC 4.1.2: aria-sort on the th, not the child button -->
<th scope="col" aria-sort="ascending">
<!-- Sort trigger — label names current state for AT users -->
<button type="button"
aria-label="Sort by Status, currently ascending. Activate to sort descending.">
Status
<!-- Visual sort indicator; aria-hidden keeps it out of the AT tree -->
<span aria-hidden="true" class="sort-icon sort-icon--asc"></span>
</button>
<!-- Filter control — entirely separate from sort semantics -->
<div class="col-filter">
<label for="status-filter" class="sr-only">Filter by Status</label>
<!-- aria-controls links input to the grid container (SC 4.1.2) -->
<!-- aria-describedby provides active-filter feedback (SC 1.3.1) -->
<input
id="status-filter"
type="text"
autocomplete="off"
aria-controls="data-grid"
aria-describedby="status-filter-hint"
placeholder="Filter Status…"
/>
<span id="status-filter-hint" class="sr-only">
Type to filter visible rows. Results announced below.
</span>
</div>
</th>
<!-- Live region: polite so it does not interrupt existing AT output -->
<!-- SC 4.1.3: status messages without focus change -->
<div id="grid-status" role="status" aria-live="polite" aria-atomic="true"></div>
<!-- Grid container with explicit dimensions for AT -->
<!-- aria-rowcount = total filtered rows; aria-colcount = visible columns -->
<table id="data-grid" aria-rowcount="-1" aria-colcount="5">
<!-- thead, tbody rows here -->
</table>
Keyboard and AT behaviour
Permalink to "Keyboard and AT behaviour"| Event / key | Expected screen reader announcement | Known AT deviation |
|---|---|---|
| Tab to sorted column header | “Status, sorted ascending, column 3 of 5” | NVDA+Firefox may omit “sorted” — add visible sort indicator as fallback |
| Activate sort button (Enter / Space) | “Status, sorted descending” + live region: “200 rows, sorted by Status descending” | JAWS 2024 announces button label only; live region must carry full state |
| Tab to filter input | “Filter by Status, edit” + hint text via aria-describedby |
VoiceOver reads hint immediately on focus; NVDA reads it after a pause |
| Type in filter input | Live region (polite): “42 of 200 rows match Active” | Avoid assertive here — it interrupts ongoing reading (SC 4.1.3) |
| Escape in filter input | Filter cleared; live region: “Filter cleared. 200 rows shown.” | No known deviations |
| Tab away from sorted header | No sort announcement expected on exit | Some older JAWS versions re-announce; not a failure but note in regression log |
Integration context
Permalink to "Integration context"This attribute operates within the sortable and filterable data grids pattern, which also governs focus preservation after sort-triggered DOM mutations and keyboard interaction contracts for multi-column sort. When building announcement strategies, coordinate aria-sort updates with aria-live regions for dynamic data — the timing relationship between the attribute update and the live region population is where most screen reader bugs originate.
For correct <th> structural semantics that make aria-sort discoverable in the first place, see correct usage of scope and headers in complex tables.
Gotchas
Permalink to "Gotchas"1. Updating aria-sort before the DOM reorders rows
Permalink to "1. Updating aria-sort before the DOM reorders rows"If you update aria-sort synchronously inside the sort-click handler, the attribute reflects the new direction before any rows have moved. Screen readers reading the grid immediately after the click will hear “sorted ascending” while the rows are still in the old order.
Fix: batch the attribute update using requestAnimationFrame so it fires in the same paint cycle as the row reorder.
function applySortToHeader(th, direction) {
// Reset all other headers first
document.querySelectorAll('th[aria-sort]').forEach(h => {
if (h !== th) h.setAttribute('aria-sort', 'none'); // SC 4.1.2
});
// Wait for the row DOM mutation to land before updating the attribute
requestAnimationFrame(() => {
th.setAttribute('aria-sort', direction); // SC 4.1.2
// Then populate the live region — after aria-sort is already updated
requestAnimationFrame(() => {
const count = document.querySelectorAll('#data-grid tbody tr:not([hidden])').length;
document.getElementById('grid-status').textContent =
`Sorted by ${th.dataset.colName} ${direction}. ${count} rows.`;
});
});
}
2. Live region flooding during rapid filter input
Permalink to "2. Live region flooding during rapid filter input"Typing quickly into a filter input fires oninput on every keystroke. If each event immediately updates an aria-live region, the screen reader queue fills with stale announcements that override the current result count.
Fix: debounce the live region update by 300–400 ms. This matches the keystroke cadence of most users and prevents announcement collisions without introducing perceptible delay.
let filterTimer;
filterInput.addEventListener('input', () => {
clearTimeout(filterTimer);
filterTimer = setTimeout(() => {
const visible = getVisibleRowCount();
const total = getTotalRowCount();
// SC 4.1.3: status message; SC 1.3.1: relationship to grid via aria-controls
statusRegion.textContent = `${visible} of ${total} rows match "${filterInput.value}"`;
}, 350);
});
3. Simultaneous sort and filter state in the column header label
Permalink to "3. Simultaneous sort and filter state in the column header label"When both a sort and a filter are active, the sort button’s aria-label must communicate both dimensions. A button labelled only “Sort by Status, ascending” tells an AT user nothing about the active filter query.
function buildSortButtonLabel(colName, sortDir, filterValue) {
const sortPart = sortDir !== 'none'
? `, sorted ${sortDir}`
: ', not sorted';
const filterPart = filterValue
? `, filtered by "${filterValue}"`
: '';
// Result: "Status, sorted ascending, filtered by "Active""
return `${colName}${sortPart}${filterPart}`;
}
FAQ
Permalink to "FAQ"Can aria-sort be placed on a button inside a th element?
No. The ARIA specification requires aria-sort to live on the columnheader role element — the <th> itself. Placing it on an interactive child violates the attribute’s host-element constraints. JAWS and NVDA read aria-sort from the cell, not from children; VoiceOver behaviour varies. Keep the attribute on <th> and describe the current state via the button’s aria-label instead.
Should aria-sort be set to "none" or removed when a column is unsorted?
Prefer aria-sort="none" over omitting the attribute entirely. Explicitly setting none signals that the column is sortable but currently inactive — a meaningful distinction from columns that cannot be sorted at all. Removing the attribute leaves the screen reader without that affordance signal. Apply none to every sortable column that is not the current sort key.
What happens to aria-sort when a filter makes all rows hidden?
The sort state remains valid as long as the underlying data order has not changed — the attribute reflects row ordering, not row visibility. Retain the current aria-sort value and let the live region announce that zero results match. Only reset aria-sort to none if the sort is also programmatically cleared. Resetting it prematurely misleads AT users into thinking the sort was removed when it was not.