Building an Accessible Sortable Table in Vue
Permalink to "Building an Accessible Sortable Table in Vue"A sortable table in Vue is three lines of reactive state and one very easy accessibility mistake. The state is simple; what breaks is timing and identity — announcing before the DOM has been patched, and keying rows by index so that sorting destroys the element the user was focused on. This page covers the working implementation: the reactive sort state, the aria-sort binding derived from it, the stable keys that preserve focus, and the nextTick announcement.
It is the Vue counterpart to building an accessible sortable table in React, and it implements the pattern described in sortable and filterable data grids.
Spec reference
Permalink to "Spec reference"Two attributes carry the semantics. aria-sort on a th with role="columnheader" — which a th has implicitly — takes ascending, descending, none or other, and exactly one header in a table may carry a value other than none. It describes the current state, and it is read when the user focuses or traverses the header.
The event needs a separate mechanism: role="status", satisfying SC 4.1.3 Status Messages, announces that a sort just happened and how many rows resulted. The two are complementary — aria-sort answers “what is the current order” for a user who arrives later, and the status message answers “what just changed” for the user who caused it.
The header itself must be a button inside the th, not a click handler on the th. SC 2.1.1 requires it to be operable by keyboard, and a button gets that plus the correct role and Enter and Space handling for free.
When to use — and when not to
Permalink to "When to use — and when not to"Use this pattern for any Vue table where the user can reorder rows by column. It scales to multi-column sorting with one change — the state becomes an array — covered in multi-column sort announcement patterns.
Do not reach for a headless table library’s sorting until you have checked what it renders. Several emit aria-sort on a wrapper div rather than the th, which is invalid, and several attach the sort handler to the cell rather than a button.
Do not sort in a watcher that also mutates the source array. Sorting in place makes the original order unrecoverable and makes “remove sort” impossible to implement correctly.
Annotated code example
Permalink to "Annotated code example"<script setup>
import { ref, computed, nextTick } from 'vue';
const props = defineProps({ rows: Array, columns: Array });
// Single source of truth for the sort
const sort = ref({ column: null, direction: 'none' });
const status = ref('');
// SC 4.1.2: aria-sort is DERIVED, so exactly one header can be non-none
function ariaSortFor(col) {
if (sort.value.column !== col.id) return 'none';
return sort.value.direction === 'asc' ? 'ascending' : 'descending';
}
const sortedRows = computed(() => {
const { column, direction } = sort.value;
if (!column || direction === 'none') return props.rows;
const dir = direction === 'asc' ? 1 : -1;
// Copy before sorting — never mutate the prop
return [...props.rows].sort((a, b) => compare(a[column], b[column]) * dir);
});
async function toggleSort(col) {
const same = sort.value.column === col.id;
sort.value = {
column: col.id,
direction: same && sort.value.direction === 'asc' ? 'desc' : 'asc',
};
// SC 4.1.3: announce AFTER Vue has patched the DOM
await nextTick();
status.value =
`Sorted by ${col.label}, ${sort.value.direction === 'asc' ? 'ascending' : 'descending'}. `
+ `${sortedRows.value.length} rows.`;
}
</script>
<template>
<p role="status" class="visually-hidden">{{ status }}</p>
<table>
<caption>Outstanding invoices</caption>
<thead>
<tr>
<!-- SC 4.1.2: aria-sort on the th; the control is a real button -->
<th v-for="col in columns" :key="col.id" scope="col" :aria-sort="ariaSortFor(col)">
<button type="button" @click="toggleSort(col)">
{{ col.label }}
</button>
</th>
</tr>
</thead>
<tbody>
<!-- SC 2.4.3: a STABLE key keeps the focused element alive across a sort -->
<tr v-for="row in sortedRows" :key="row.id">
<th scope="row">{{ row.account }}</th>
<td>{{ row.amount }}</td>
</tr>
</tbody>
</table>
</template>
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | Context | Expected result | Failure indicator |
|---|---|---|---|
| Tab | Header row | Focus moves to each header button | The th takes focus but nothing activates |
| Enter | Header button | Sort toggles; state and status both update | The sort applies but nothing is announced |
| Space | Header button | Identical to Enter | The page scrolls instead |
| Tab after sorting | Header button | Focus is still on the same button | Focus is on document.body — an unstable key |
Integration context
Permalink to "Integration context"The status region should be a single element for the whole table, not one per column. If the table also filters, route both announcements through one function so a filter that resets the sort produces one sentence rather than two competing ones — the combined pattern is described in accessible filtering and faceted search.
Where the table is also virtualized, the sort applies to the data array and the window re-renders from it; aria-rowcount is unaffected because sorting does not change the number of rows.
Gotchas
Permalink to "Gotchas"Announcing in the click handler. The message is composed before the patch, so it can describe the previous row count. await nextTick() first.
Keying by index. Covered above, and worth checking even when it looks fine — the symptom only appears when the sort actually reorders rows.
Mutating the prop array. props.rows.sort() sorts in place and mutates the parent’s data. Copy first.
Two headers claiming a sort. Storing the string per column rather than deriving it produces this. Derive.
Testing the Vue implementation
Permalink to "Testing the Vue implementation"Two component tests cover almost everything that regresses here, and both are cheap enough to belong in every pull request.
The first asserts the attribute. Mount the table, activate a header button, and assert that the th for that column now carries aria-sort="ascending" and that no other th carries a non-none value. That single assertion catches the derived-versus-stored bug, the wrong-element bug where the attribute lands on a wrapper, and the multiple-sorted-columns bug in one go.
The second asserts focus identity. Record document.activeElement, activate the sort, wait for nextTick, and assert that document.activeElement is still the same node. If the key is unstable, this test fails immediately and unambiguously — which is far better than discovering it during a screen reader pass three weeks later.
Add a third if the table is used in more than one place: assert that the status text contains the column label and the row count. It is a weak assertion by design, because it should survive a copy change, but it does prove the announcement exists at all.
FAQ
Permalink to "FAQ"Where should the announcement be written in Vue?
Inside nextTick, or in a watcher that runs after the DOM has been patched. Writing it in the click handler means the message describes a state the DOM has not reached yet, and on VoiceOver in particular the announcement is dropped because the region is written before the rows change.
Does aria-sort need to be a computed property?
It does not have to be, but deriving it is safer than storing it. A function that maps the current sort state to “ascending”, “descending” or “none” for a given column guarantees that exactly one header carries a non-none value. Storing the string per column invites the state where two columns both claim to be sorted.
Why does focus jump to the top of the page after sorting?
Almost always an unstable :key. When rows are keyed by array index, reordering changes which data each key maps to, so Vue patches the DOM by replacing elements rather than moving them — and the focused element is destroyed. Key by a stable row id and the focused cell survives the sort.
Related
Permalink to "Related"- Sortable & filterable data grids — the parent guide and the framework-agnostic pattern
- Building an accessible sortable table in React — the same problem in a different framework
- Multi-column sort announcement patterns — what changes when the sort has more than one key