Multi-Column Sort Announcement Patterns
Permalink to "Multi-Column Sort Announcement Patterns"Multi-column sorting is the point where aria-sort runs out. The attribute expresses one column’s direction, only one header may carry a non-none value, and there is no ARIA property for “this is the second sort key”. Everything beyond the primary column has to be carried by accessible names and by the status message. This page covers how to express a sort chain that a screen reader user can actually reason about, and the interaction model that builds it from the keyboard.
It extends sortable and filterable data grids, and it assumes the single-column pattern from aria-sort attributes for accessible column filtering.
Spec reference
Permalink to "Spec reference"aria-sort is defined on columnheader and rowheader roles with the values ascending, descending, none and other. The specification is explicit that only one header in a table should have a value other than none — the attribute describes the table’s sort, not each column’s participation in it.
That constraint is not a defect to work around with invalid markup. It reflects the fact that ARIA has no vocabulary for ordered sort keys, so the information has to travel through mechanisms that do: the accessible name of each header, and a status message satisfying SC 4.1.3.
SC 2.1.1 Keyboard applies to the chain-building interaction. If Shift plus click adds a column, then Shift plus Enter must do the same, and — because a modifier chord is not available to every user — the same operation should exist as an item in the column menu.
When to use — and when not to
Permalink to "When to use — and when not to"Use multi-column sorting where the data has natural groupings that users compare within: region then revenue, status then date, owner then priority.
Do not add it to a table with fewer than about four columns, or where nobody has asked for it. Every additional sort key is state the user has to track by ear, and a chain of four columns is genuinely hard to hold in mind when you cannot see the header indicators.
Do not implement it as an invisible power feature. If the only route is Shift plus click, most users will never find it, and users who cannot perform a chord cannot use it at all.
Annotated code example
Permalink to "Annotated code example"// The chain is an ordered array — position IS the priority
let chain = []; // [{ id: 'region', dir: 'asc' }, …]
function onHeaderActivate(col, shiftKey) {
const existing = chain.find((s) => s.id === col.id);
if (shiftKey) {
if (existing) existing.dir = existing.dir === 'asc' ? 'desc' : 'asc';
else chain.push({ id: col.id, dir: 'asc' }); // append to the chain
} else {
chain = [{ id: col.id, dir: existing && existing.dir === 'asc' ? 'desc' : 'asc' }];
}
applySort(chain);
syncHeaderSemantics();
requestAnimationFrame(() => announce(describeChain(chain)));
}
// SC 4.1.2: aria-sort on the PRIMARY only; priority goes in the names
function syncHeaderSemantics() {
for (const th of headers) {
const index = chain.findIndex((s) => s.id === th.dataset.colId);
const entry = chain[index];
if (index === 0) {
th.setAttribute('aria-sort', entry.dir === 'asc' ? 'ascending' : 'descending');
} else {
th.removeAttribute('aria-sort'); // never more than one non-none
}
const button = th.querySelector('button');
button.setAttribute('aria-label', entry
? `${th.dataset.label}, sort ${index + 1} of ${chain.length}, ` +
`${entry.dir === 'asc' ? 'ascending' : 'descending'}`
: `${th.dataset.label}, not sorted`);
}
}
function describeChain(chain) {
if (!chain.length) return 'Sort cleared. Original order restored.';
const parts = chain.map((s) =>
`${labelOf(s.id)} ${s.dir === 'asc' ? 'ascending' : 'descending'}`);
return `Sorted by ${parts.join(', then ')}. ${rowCount()} rows.`;
}
describeChain is the part worth copying verbatim. “Sorted by Region ascending, then Amount descending. 412 rows.” is a sentence a listener can hold in mind, and the word “then” does the work that a numbered indicator does visually.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | Action | Announcement |
|---|---|---|
| Enter / Space | Replace the chain with this column | The new single-column chain and the row count |
| Shift + Enter | Append this column, or toggle it in place | The full chain in priority order |
| Enter on the primary column | Toggle its direction | The full chain, not just the toggled column |
| Escape (in the column menu) | Close without changing the sort | Focus returns to the header button |
| “Clear sort” menu item | Empty the chain | “Sort cleared. Original order restored.” |
Integration context
Permalink to "Integration context"Sort chains and column reordering interact badly if either is keyed by position. Store chain entries by column id, so a move from resizable, reorderable and frozen columns carries the sort with the column rather than leaving it on whatever now occupies that slot.
Sort chains also interact with pagination: changing the sort resets to page 1. Announce both in one sentence rather than two — “Sorted by Region ascending, then Amount descending. Page 1 of 9, 412 rows.”
Gotchas
Permalink to "Gotchas"aria-sort on every sorted column. Invalid, and readers disagree about which one to announce. Primary only.
The name and the attribute disagree. If the accessible name says “second sort, descending” while aria-sort is absent, that is correct. If the name says ascending while the attribute says descending, one of them was updated and the other was not — derive both from the same state in the same pass.
Announcing only the delta. “Amount added” tells a user nothing about the resulting order. Always describe the whole chain.
No way to clear. A chain that can only be replaced, never emptied, traps the user in a sorted view. Provide an explicit clear.
FAQ
Permalink to "FAQ"Can more than one column carry aria-sort at once?
No. The ARIA specification allows aria-sort with a value other than none on only one header per table. Setting it on several produces undefined behaviour and readers disagree about which to believe. Put aria-sort on the primary sort column and express the rest of the chain in the accessible names and in the status message.
How should sort priority be exposed to a screen reader?
In two places. In the header button’s accessible name — “Amount, second sort, descending” — so a user traversing the headers learns the chain without any interaction, and in the status message after each change, which states the whole chain in priority order. Neither alone is sufficient: the name serves later exploration, the message serves the moment.
What modifier should add a column to the sort chain?
Shift plus activation is the established convention and it matches what most desktop applications do. Whatever you choose, expose the same operation in the column menu as an explicit item — “Add to sort” — because a modifier combination is not discoverable and, for some users, not physically available.
Related
Permalink to "Related"- Sortable & filterable data grids — the parent guide
- aria-sort attributes for accessible column filtering — the single-column foundation
- NVDA vs JAWS aria-sort announcement differences — why the status message carries the load