Building an Accessible Sortable Table in Angular
Permalink to "Building an Accessible Sortable Table in Angular"An accessible sortable table in Angular is a <table> whose sortable column headers contain a native <button>, carry aria-sort reflecting the current state, and trigger one polite announcement through the CDK LiveAnnouncer once the rows have re-rendered. It prevents the most common sortable-table failure: a click that visibly reorders the rows while a screen reader user hears nothing at all.
Angular Material’s matSort gets part of this right out of the box and part of it wrong, so this page shows both a plain Angular implementation and the adjustments matSort needs. It is the Angular counterpart to the React and Vue versions, and follows the attribute rules in aria-sort attributes for accessible column filtering.
Spec reference
Permalink to "Spec reference"aria-sort is defined in ARIA 1.2 for elements with role columnheader or rowheader, with the values ascending, descending, other and none. Only one header in a table should carry a value other than none at a time; for multi-column sort the secondary columns omit the attribute and the order is described in text.
The CDK LiveAnnouncer (@angular/cdk/a11y) maintains a single visually hidden live region appended to document.body and exposes announce(message, politeness), returning a promise. It clears and re-sets the region’s text so repeated identical messages are still spoken.
Success criteria in play: SC 1.3.1 Info and Relationships for the header state, SC 4.1.2 Name, Role, Value for the sort control, and SC 4.1.3 Status Messages for the announcement that the table has been re-ordered.
When to use this pattern — and when not to
Permalink to "When to use this pattern — and when not to"Use it for any table where sorting happens client-side or through a request that returns within a second or two. The pattern assumes the table remains a static <table> — users read it with table commands, and the only interactive elements are the header buttons.
Do not turn the table into role="grid" just because it sorts. A sortable table is still a table; arrow-key cell navigation is only warranted when cells themselves are interactive, as discussed in choosing between grid and table roles.
The Material-specific misapplication is putting mat-sort-header on the <th> and stopping there. The directive renders its own button and sets aria-sort, but its default announcement is built from sortActionDescription, which is announced as the button’s description before activation, not as a status message after it. Users hear “sort by amount” and then silence.
Annotated code example
Permalink to "Annotated code example"// sortable-table.component.ts — plain Angular, no Material
import { Component, computed, inject, signal } from '@angular/core';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { afterNextRender, Injector } from '@angular/core';
type Dir = 'ascending' | 'descending';
interface Col { key: keyof Invoice; label: string; }
@Component({
selector: 'app-sortable-table',
templateUrl: './sortable-table.component.html',
})
export class SortableTableComponent {
private announcer = inject(LiveAnnouncer);
private injector = inject(Injector);
cols: Col[] = [
{ key: 'id', label: 'Invoice' },
{ key: 'customer', label: 'Customer' },
{ key: 'amount', label: 'Amount' },
];
rows = signal<Invoice[]>(INVOICES);
sortKey = signal<keyof Invoice | null>(null);
sortDir = signal<Dir>('ascending');
sorted = computed(() => {
const k = this.sortKey();
if (!k) return this.rows();
const f = this.sortDir() === 'ascending' ? 1 : -1;
return [...this.rows()].sort((a, b) => (a[k] > b[k] ? f : a[k] < b[k] ? -f : 0));
});
sortBy(col: Col) {
const same = this.sortKey() === col.key;
this.sortDir.set(same && this.sortDir() === 'ascending' ? 'descending' : 'ascending');
this.sortKey.set(col.key);
// SC 4.1.3: announce only after the new order is in the DOM
afterNextRender(() => {
this.announcer.announce(
`Sorted by ${col.label}, ${this.sortDir()}.`, 'polite');
}, { injector: this.injector });
}
ariaSort(col: Col) { // SC 1.3.1 + 4.1.2
return this.sortKey() === col.key ? this.sortDir() : null; // null removes it
}
trackById = (_: number, r: Invoice) => r.id;
}
<!-- sortable-table.component.html -->
<table>
<caption>Open invoices</caption> <!-- SC 1.3.1 -->
<thead>
<tr>
@for (col of cols; track col.key) {
<!-- aria-sort lives on the th (columnheader), never on the button -->
<th scope="col" [attr.aria-sort]="ariaSort(col)">
<!-- SC 4.1.2: a native button — Enter and Space for free -->
<button type="button" class="sort-btn" (click)="sortBy(col)">
{{ col.label }}
<span aria-hidden="true" class="sort-icon"></span>
</button>
</th>
}
</tr>
</thead>
<tbody>
<!-- track keeps row elements stable, so focus and reading position survive -->
@for (row of sorted(); track row.id) {
<tr>
<th scope="row">{{ row.id }}</th>
<td>{{ row.customer }}</td>
<td>{{ row.amount | number:'1.2-2' }}</td>
</tr>
}
</tbody>
</table>
With Angular Material, keep matSort for the state machine but add the announcement yourself: subscribe to matSortChange, and call the announcer in the same afterNextRender wrapper. Material already sets aria-sort on the header cell in current versions; verify it in the accessibility tree rather than adding a second binding that fights it.
// Material: add the missing post-sort status message
onSortChange(e: Sort) {
const label = this.cols.find(c => c.key === e.active)?.label ?? e.active;
afterNextRender(() => this.announcer.announce(
e.direction ? `Sorted by ${label}, ${e.direction}ending.` : 'Sort cleared.',
'polite'), { injector: this.injector });
}
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key / event | Expected announcement | AT-specific deviations |
|---|---|---|
Tab to header button |
“Amount, button” plus column context | JAWS adds “column header, not sorted” when aria-sort is absent on others |
Enter / Space |
“Sorted by Amount, ascending.” | NVDA may also re-read the header with “sorted ascending” |
Second Enter |
“Sorted by Amount, descending.” | VoiceOver drops the message if it fires before the rows render |
| Table navigation into header | “Amount, sorted ascending, column header” | TalkBack reads the state only on focus, not on navigation |
Integration context
Permalink to "Integration context"This component covers single-column sort. When users can add secondary sort keys, the announcement wording changes — see multi-column sort announcement patterns. The live region itself follows the rules in aria-live regions for dynamic data; the CDK announcer is one well-behaved implementation of that pattern.
If the table is inside a routed view, remember the announcer region lives on document.body and survives route changes — good for this use, but it means a message queued just before navigation may be spoken on the next page.
Gotchas
Permalink to "Gotchas"Announcing before render. Calling announce() synchronously inside the click handler runs before change detection. The message is correct but some readers — VoiceOver particularly — speak it and then re-read the old focused header. afterNextRender (or setTimeout(0) in older versions) fixes the ordering.
Missing track. Without a stable track expression, Angular destroys and recreates every row on sort. The user’s virtual cursor, if it was inside the table, is thrown to the top of the page.
aria-sort="none" on every column. It is valid but noisy: JAWS announces “not sorted” on every header. Remove the attribute from unsorted columns instead.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Where should the LiveAnnouncer message come from in a large Angular app?
From the one CDK LiveAnnouncer service, injected wherever it is needed. It maintains a single live region on the page, which avoids several components each creating their own regions that compete or fail to announce.
Does Angular Material's matSort make a table accessible on its own?
Partly. It renders a button in each header and manages aria-sort on current versions, but it does not announce the result of a sort as a status message. Add a matSortChange handler that calls the CDK LiveAnnouncer after the next render.
Why use afterNextRender instead of calling announce directly?
Because the announcement should describe what is on screen. Calling it in the click handler runs before change detection, so on slower devices the reader can speak the message and then re-read stale content. Waiting for the next render keeps the message and the DOM in step.
Should the sort button's label include the sort state?
No. The state belongs in aria-sort on the header cell, where every reader expects it. Duplicating it in the button text makes some readers announce it twice, and the button label then changes on every activation.
Related
Permalink to "Related"- Sortable table in React — the same contract in React
- Sortable table in Vue — the same contract in Vue
- aria-sort attributes — the attribute rules this component follows