Accessible Sortable Table with aria-sort
Sort an HTML data table with real buttons, keep aria-sort on the active column, and announce each sort change to screen readers.
Code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sortable release history</title>
<style>
body {
margin: 0;
padding: 2rem;
font-family: system-ui, sans-serif;
}
table {
border-collapse: collapse;
width: 100%;
max-width: 44rem;
}
caption {
text-align: left;
font-weight: 700;
padding-bottom: 0.75rem;
}
th,
td {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid gray;
text-align: left;
}
th button {
font: inherit;
font-weight: 700;
border: 0;
background: none;
color: inherit;
padding: 0;
cursor: pointer;
}
th button:focus-visible {
outline: 3px solid #4f46e5;
outline-offset: 3px;
}
.indicator {
margin-left: 0.35rem;
}
</style>
</head>
<body>
<main>
<h1>Release history</h1>
<table id="releases">
<caption>Atlas API releases, sortable by version, date, or downloads</caption>
<thead>
<tr>
<th scope="col" aria-sort="ascending">
<button type="button" data-type="text" data-label="version">
Version <span class="indicator" aria-hidden="true">▲</span>
</button>
</th>
<th scope="col" aria-sort="none">
<button type="button" data-type="date" data-label="release date">
Released <span class="indicator" aria-hidden="true">↕</span>
</button>
</th>
<th scope="col" aria-sort="none">
<button type="button" data-type="number" data-label="downloads">
Downloads <span class="indicator" aria-hidden="true">↕</span>
</button>
</th>
</tr>
</thead>
<tbody>
<tr>
<td data-value="1.4.0">v1.4.0</td>
<td data-value="2026-03-04">4 Mar 2026</td>
<td data-value="980">980</td>
</tr>
<tr>
<td data-value="1.9.2">v1.9.2</td>
<td data-value="2026-08-01">1 Aug 2026</td>
<td data-value="301">301</td>
</tr>
<tr>
<td data-value="1.10.0">v1.10.0</td>
<td data-value="2026-09-12">12 Sep 2026</td>
<td data-value="142">142</td>
</tr>
</tbody>
</table>
<p id="sort-status" role="status" aria-live="polite"></p>
</main>
<script>
const table = document.querySelector('#releases');
const tbody = table.tBodies[0];
const status = document.querySelector('#sort-status');
const headers = [...table.querySelectorAll('th')];
let activeHeader = headers[0];
let direction = 'ascending';
function cellValue(row, index, type) {
const cell = row.cells[index];
const raw = cell.dataset.value ?? cell.textContent.trim();
if (type === 'number') return Number(raw);
return raw;
}
function updateIndicators() {
for (const header of headers) {
const indicator = header.querySelector('.indicator');
if (!indicator) continue;
if (header === activeHeader) {
indicator.textContent = direction === 'ascending' ? '▲' : '▼';
} else {
indicator.textContent = '↕';
}
}
}
for (const header of headers) {
const button = header.querySelector('button');
if (!button) continue;
button.addEventListener('click', () => {
const columnIndex = header.cellIndex;
const type = button.dataset.type;
direction = header === activeHeader && direction === 'ascending'
? 'descending'
: 'ascending';
for (const candidate of headers) {
candidate.setAttribute('aria-sort', candidate === header ? direction : 'none');
}
activeHeader = header;
updateIndicators();
const rows = [...tbody.rows];
rows.sort((left, right) => {
const a = cellValue(left, columnIndex, type);
const b = cellValue(right, columnIndex, type);
let result;
if (type === 'number') {
result = a - b;
} else if (type === 'date') {
result = String(a).localeCompare(String(b));
} else {
result = String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
}
return direction === 'ascending' ? result : -result;
});
tbody.append(...rows);
status.textContent = 'Sorted by ' + button.dataset.label + ' ' + direction + '.';
});
}
</script>
</body>
</html>Report a problem with this exampleOpens a pre-filled issue on GitHub.