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.

Where each concern lives in a Vue sortable table Stack of four layers in a Vue sortable table: reactive sort state, the header cell binding, the computed sorted rows and the status announcer. Where each concern lives in a Vue sortable tableReactive sort stateref({{ column, direction }}) — the single source of truthHeader cell:aria-sort bound directly to the stateComputed rowscomputed(() => sortRows(rows, sort.value)) keyed by a stable idStatus announcerwritten in nextTick, after the DOM has updated
Computed rows keep the sort derived rather than stored, which prevents state drift.

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>
One Vue update cycle, in accessibility order Four-step Vue sort cycle: the click updates reactive state, the header re-renders with the new aria-sort, the computed rows recompute, and nextTick writes the announcement. One Vue update cycle, in accessibility orderClick updates statesort.value = {{ column: "amount", direction: "asc" }}Pure state, no DOM workHeader re-renders:aria-sort="ariaSortFor(col)"Same element, so focus survivesRows recomputecomputed sorted list, :key="row.id"Stable keys keep cells from remountingnextTick announces"Sorted by Amount, ascending. 412 rows."After the patch, never during it
nextTick is where the announcement belongs — the DOM is settled there.

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.

Vue patterns that keep focus, and one that loses it Comparison of Vue rendering choices that preserve the focused header button across a sort against a pattern that remounts it. Vue patterns that keep focus, and one that loses it✓ Keeps focus:key="row.id" — a stable identifierMutating aria-sort on the existing buttonA computed sorted list rather than a replacedarray✗ Loses focus:key="index" — every row remounts on reorderv-if swapping the header button for a differentelementReplacing the whole table with a freshcomponent instance
An index key is the single most common cause of lost focus in a Vue table.

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.

Permalink to "Related"

Back to Sortable & Filterable Data Grids