Sortable table
Click a column to sort, with aria-sort announced and no library.
<table class="data" id="tbl">
<caption>Top pages last month. Select a column heading to sort.</caption>
<thead>
<tr>
<th scope="col" aria-sort="none"><button type="button">Page</button></th>
<th scope="col" aria-sort="none" class="num" data-type="number"><button type="button">Views</button></th>
<th scope="col" aria-sort="none" class="num" data-type="number"><button type="button">Avg. time</button></th>
<th scope="col" aria-sort="none" class="num" data-type="number"><button type="button">Bounce</button></th>
<th scope="col" aria-sort="none" data-type="date"><button type="button">Updated</button></th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">/html-formatter/</th>
<td class="num" data-value="18420">18,420</td>
<td class="num" data-value="196">3m 16s</td>
<td class="num" data-value="42.1">42.1%</td>
<td data-value="2026-04-02"><time datetime="2026-04-02">2 Apr</time></td>
</tr>
<tr>
<th scope="row">/json-formatter/</th>
<td class="num" data-value="9315">9,315</td>
<td class="num" data-value="241">4m 01s</td>
<td class="num" data-value="35.8">35.8%</td>
<td data-value="2026-03-28"><time datetime="2026-03-28">28 Mar</time></td>
</tr>
<tr>
<th scope="row">/css-minifier/</th>
<td class="num" data-value="4102">4,102</td>
<td class="num" data-value="122">2m 02s</td>
<td class="num" data-value="58.4">58.4%</td>
<td data-value="2026-04-06"><time datetime="2026-04-06">6 Apr</time></td>
</tr>
<tr>
<th scope="row">/base64-encoder/</th>
<td class="num" data-value="2870">2,870</td>
<td class="num" data-value="88">1m 28s</td>
<td class="num" data-value="61.9">61.9%</td>
<td data-value="2026-02-14"><time datetime="2026-02-14">14 Feb</time></td>
</tr>
<tr>
<th scope="row">/templates/</th>
<td class="num" data-value="12044">12,044</td>
<td class="num" data-value="310">5m 10s</td>
<td class="num" data-value="28.3">28.3%</td>
<td data-value="2026-04-09"><time datetime="2026-04-09">9 Apr</time></td>
</tr>
</tbody>
</table>
<p class="live" id="live" role="status"></p>* { box-sizing: border-box; margin: 0; }
body { font: 16px/1.6 system-ui, -apple-system, sans-serif; padding: 22px; background: #fff; }
.data { width: 100%; border-collapse: collapse; font-size: .92rem; }
caption { text-align: left; font-size: .84rem; color: #8593ab; padding-bottom: 10px; }
.data th, .data td { padding: 0; text-align: left; border-bottom: 1px solid #eef1f6; }
.data td, .data tbody th { padding: 11px 14px; }
.data thead th { background: #f6f8fb; border-bottom: 1px solid #e2e7ef; }
/* the header is a button, so it is focusable and announces itself */
.data thead button {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
font: inherit;
font-size: .8rem;
font-weight: 600;
color: #5b6b83;
padding: 11px 14px;
border: 0;
background: none;
cursor: pointer;
text-align: inherit;
white-space: nowrap;
}
.data thead .num button { justify-content: flex-end; }
.data thead button:hover { color: #16202e; background: #eef1f6; }
.data thead button:focus-visible { outline: 2px solid #3b4fe4; outline-offset: -2px; }
/* the arrow: faint until this column is the sorted one */
.data thead button::after {
content: "";
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 5px solid currentColor;
opacity: .25;
}
th[aria-sort="ascending"] button::after { opacity: 1; color: #3b4fe4; }
th[aria-sort="descending"] button::after { opacity: 1; color: #3b4fe4; transform: rotate(180deg); }
th[aria-sort="ascending"] button,
th[aria-sort="descending"] button { color: #16202e; }
.data tbody th { font-weight: 500; color: #16202e; font-family: ui-monospace, SFMono-Regular, monospace; font-size: .86rem; }
.data td { color: #5b6b83; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.data tbody tr:hover { background: #f9fbfd; }
.live { margin-top: 12px; font-size: .84rem; color: #8593ab; }const table = document.getElementById("tbl");
const body = table.tBodies[0];
const live = document.getElementById("live");
const headers = [...table.tHead.rows[0].cells];
// use data-value when present: "3m 16s" cannot be compared, 196 can
function cellValue(row, i) {
const cell = row.cells[i];
const raw = cell.dataset.value ?? cell.textContent.trim();
const type = headers[i].dataset.type;
if (type === "number") return parseFloat(String(raw).replace(/[^0-9.-]/g, "")) || 0;
if (type === "date") return new Date(raw).getTime() || 0;
return String(raw).toLowerCase();
}
function sortBy(i, dir) {
const rows = [...body.rows];
rows.sort((a, b) => {
const x = cellValue(a, i), y = cellValue(b, i);
const cmp = typeof x === "string" ? x.localeCompare(y) : x - y;
return dir === "ascending" ? cmp : -cmp;
});
rows.forEach((r) => body.appendChild(r)); // appendChild moves, it does not copy
}
headers.forEach((th, i) => {
const btn = th.querySelector("button");
if (!btn) return;
btn.addEventListener("click", () => {
const next = th.getAttribute("aria-sort") === "ascending" ? "descending" : "ascending";
headers.forEach((h) => h.setAttribute("aria-sort", "none"));
th.setAttribute("aria-sort", next);
sortBy(i, next);
live.textContent = `Sorted by ${btn.textContent.trim()}, ${next}.`;
});
});How it works
The header is a button inside the th, not the th itself. A click handler on a table cell is unreachable by keyboard. A real <button> is focusable, activates on Enter and Space, and announces itself. The <th> keeps its scope and its aria-sort.
aria-sort is the whole accessibility story. It goes on the <th>, takes ascending, descending or none, and only one column may be sorted at a time. Screen readers announce it when the user lands on the column. It also drives the arrow's appearance, so the visual and announced states cannot disagree.
data-value separates display from sort key. "3m 16s" and "£402.50" and "2 Apr" are all unsortable as text. Putting the machine value in data-value lets you format the display however you like — and it is exactly what the datetime attribute already does for <time>.
appendChild moves nodes. Appending an element that is already in the document removes it from its old position first, so re-appending rows in sorted order reorders them without cloning anything. Event listeners on those rows survive.
localeCompare, not the < operator. A plain comparison sorts by code point, which puts "Zebra" before "apple" and mangles accented characters. localeCompare gets both right.
The status line matters more than it looks. Sorting reorders content silently. Without an announcement, a screen reader user presses the button and nothing appears to happen.
Accessibility notes
Only one column carries a sort state at a time. Leaving aria-sort set on a previously sorted column is the most common bug in sortable tables, and the loop above clears them all before setting the new one.
The status region uses role="status", which is polite — it waits for a pause rather than interrupting.
The arrow is a pseudo-element, so it adds nothing to the accessible name. The sort state comes from aria-sort, which is where it belongs.
Making it yours
For hundreds of rows, sort on the server or in a worker — reordering thousands of DOM nodes will drop frames. Under about five hundred rows this is fine.
To sort on load, call sortBy(1, "descending") and set the matching aria-sort on that header.
Add a text filter above the table and hide non-matching rows with hidden. Keep the sort and filter independent so applying one does not reset the other.
Related templates
Responsive data table
A real table that stays readable at 390px, using a label-per-cell stack.
HTMLCSSFeature comparison table
A plan comparison with a sticky header row and a highlighted column.
HTMLCSSHTML email boilerplate
A table-based email shell that survives Outlook and Gmail.
HTMLAccessible dropdown menu
A nav dropdown that opens on hover, click and keyboard, with arrow-key support.
HTMLCSSJavaScriptCheck your version
Once you have edited this, the HTML validator will catch any tag you left unclosed, and the formatter will tidy the indentation. Both run in your browser.