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.

One sort, in order Five-stage flow for an Angular sort: button click, state update, change detection re-renders rows, aria-sort updated, LiveAnnouncer message after render. One sort, in orderClick sortbuttonnative buttoninside the thUpdate sortstatesignal orcomponent fieldRe-renderrowstrackBy keeps rowelements stablearia-sortboundascending ordescending on onethAnnounceLiveAnnouncer,polite, afterrender
The announcement comes last — after the DOM reflects the new order, never before.

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
What a user hears across one activation Timeline of a single sort activation from pressing Enter to hearing the header state and then the status message. What a user hears across one activationEnter pressedbutton activatedState setsignals updateRows re-renderedchange detection finishesHeader state"sorted ascending" on re-readStatus message"Sorted by Amount, ascending."one sort activation, left to right
Two announcements: the header state from aria-sort, then the status message confirming the reorder.

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.

matSort alone versus matSort with an announcer Comparison of what a screen reader user gets from Angular Material's matSort by itself against matSort plus a LiveAnnouncer call after render. matSort alone versus matSort with an announcer✗ matSort by itselfHeader button and aria-sort are renderedsortActionDescription read before activationNo message after the rows reorderUsers re-read the column to check the result✓ matSort plus LiveAnnouncerSame header button and aria-sortmatSortChange handler builds a messageafterNextRender waits for the new order"Sorted by Amount, descending." once
The directive handles state; the confirmation that the rows actually moved is yours to add.

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.

Permalink to "Related"

← Back to Sortable & Filterable Data Grids