Sortable table
Category: Tables & lists · Tags: table, sort, aria-sort, columns, live-region
| Design | Team | 12 | $240 |
| Finance | Starter | 4 | $40 |
| Marketing | Team | 9 | $180 |
| Support | Business | 25 | $875 |
Sorted by Team, ascending.
How to use it
Use when people need to reorder a modest table, a few hundred rows at most, by one column at a time. Do not use client-side sorting for paginated or very large data (sort on the server), and do not make every column sortable if the order means nothing. After copying, set data-type="number" on numeric headers, give each cell a data-value with the raw value to sort by, and update the caption.
Accessibility
Each sortable column header is a th containing a native <button>, so sorting works with Tab plus Enter or Space; the sorted column carries aria-sort="ascending" or "descending" (and only that one), and a polite role="status" line announces the new order, such as "Sorted by Seats, descending". The arrow glyphs are aria-hidden so they are not read twice. Cell text is #0f172a on #ffffff (17.85:1), header buttons #0f172a on #f1f5f9 (16.30:1) or on #e2e8f0 when hovered (14.48:1), and the status text #334155 on #ffffff (10.35:1).
Source (copy-ready)
<div class="dcl-sortable-table">
<div class="dcl-sortable-table__scroll">
<table class="dcl-sortable-table__table">
<caption class="dcl-sortable-table__caption">Team subscriptions</caption>
<thead>
<tr>
<th scope="col" class="dcl-sortable-table__head" aria-sort="ascending"><button type="button" class="dcl-sortable-table__sort">Team <span class="dcl-sortable-table__icon" aria-hidden="true">↑</span></button></th>
<th scope="col" class="dcl-sortable-table__head"><button type="button" class="dcl-sortable-table__sort">Plan <span class="dcl-sortable-table__icon" aria-hidden="true">↕</span></button></th>
<th scope="col" class="dcl-sortable-table__head dcl-sortable-table__num" data-type="number"><button type="button" class="dcl-sortable-table__sort">Seats <span class="dcl-sortable-table__icon" aria-hidden="true">↕</span></button></th>
<th scope="col" class="dcl-sortable-table__head dcl-sortable-table__num" data-type="number"><button type="button" class="dcl-sortable-table__sort">Monthly cost <span class="dcl-sortable-table__icon" aria-hidden="true">↕</span></button></th>
</tr>
</thead>
<tbody class="dcl-sortable-table__body">
<tr><td class="dcl-sortable-table__cell" data-value="Design">Design</td><td class="dcl-sortable-table__cell" data-value="Team">Team</td><td class="dcl-sortable-table__cell dcl-sortable-table__num" data-value="12">12</td><td class="dcl-sortable-table__cell dcl-sortable-table__num" data-value="240">$240</td></tr>
<tr><td class="dcl-sortable-table__cell" data-value="Finance">Finance</td><td class="dcl-sortable-table__cell" data-value="Starter">Starter</td><td class="dcl-sortable-table__cell dcl-sortable-table__num" data-value="4">4</td><td class="dcl-sortable-table__cell dcl-sortable-table__num" data-value="40">$40</td></tr>
<tr><td class="dcl-sortable-table__cell" data-value="Marketing">Marketing</td><td class="dcl-sortable-table__cell" data-value="Team">Team</td><td class="dcl-sortable-table__cell dcl-sortable-table__num" data-value="9">9</td><td class="dcl-sortable-table__cell dcl-sortable-table__num" data-value="180">$180</td></tr>
<tr><td class="dcl-sortable-table__cell" data-value="Support">Support</td><td class="dcl-sortable-table__cell" data-value="Business">Business</td><td class="dcl-sortable-table__cell dcl-sortable-table__num" data-value="25">25</td><td class="dcl-sortable-table__cell dcl-sortable-table__num" data-value="875">$875</td></tr>
</tbody>
</table>
</div>
<p class="dcl-sortable-table__status" role="status">Sorted by Team, ascending.</p>
</div>
<style>
.dcl-sortable-table { max-width: 100%; font: inherit; color: #0f172a; background-color: #ffffff; }
.dcl-sortable-table__scroll { max-width: 100%; overflow-x: auto; border: 1px solid #cbd5e1; border-radius: 8px; }
.dcl-sortable-table__table { width: 100%; min-width: 26rem; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.dcl-sortable-table__caption { padding: 0.75rem; text-align: left; font-weight: 700; color: #0f172a; background-color: #ffffff; }
.dcl-sortable-table__head { padding: 0; text-align: left; border-bottom: 2px solid #cbd5e1; color: #0f172a; background-color: #f1f5f9; }
.dcl-sortable-table__sort { display: block; width: 100%; padding: 0.6rem 0.75rem; border: none; text-align: inherit; font: inherit; font-weight: 700; white-space: nowrap; cursor: pointer; color: #0f172a; background-color: #f1f5f9; }
.dcl-sortable-table__sort:hover { color: #0f172a; background-color: #e2e8f0; }
.dcl-sortable-table__sort:focus-visible { outline: 2px solid #1d4ed8; outline-offset: -2px; }
.dcl-sortable-table__icon { display: inline-block; width: 1em; }
.dcl-sortable-table__cell { padding: 0.6rem 0.75rem; border-top: 1px solid #e2e8f0; color: #0f172a; background-color: #ffffff; }
.dcl-sortable-table__num { text-align: right; }
.dcl-sortable-table__status { margin: 0.5rem 0 0; font-size: 0.9rem; color: #334155; background-color: #ffffff; }
</style>
<script>
(function () {
document.querySelectorAll(".dcl-sortable-table").forEach(function (root) {
if (root.dataset.dclBound) { return; }
root.dataset.dclBound = "true";
var body = root.querySelector(".dcl-sortable-table__body");
var status = root.querySelector(".dcl-sortable-table__status");
var headers = Array.prototype.slice.call(root.querySelectorAll(".dcl-sortable-table__head"));
if (!body || !headers.length) { return; }
headers.forEach(function (header, column) {
var button = header.querySelector(".dcl-sortable-table__sort");
if (!button) { return; }
button.addEventListener("click", function () {
var direction = header.getAttribute("aria-sort") === "ascending" ? "descending" : "ascending";
var numeric = header.getAttribute("data-type") === "number";
var rows = Array.prototype.slice.call(body.rows);
rows.sort(function (a, b) {
var cellA = a.cells[column], cellB = b.cells[column];
var x = cellA ? (cellA.getAttribute("data-value") || cellA.textContent) : "";
var y = cellB ? (cellB.getAttribute("data-value") || cellB.textContent) : "";
var result = numeric ? (parseFloat(x) || 0) - (parseFloat(y) || 0) : x.localeCompare(y);
return direction === "ascending" ? result : -result;
});
rows.forEach(function (row) { body.appendChild(row); });
headers.forEach(function (other) {
var icon = other.querySelector(".dcl-sortable-table__icon");
if (other === header) {
other.setAttribute("aria-sort", direction);
if (icon) { icon.textContent = direction === "ascending" ? "↑" : "↓"; }
} else {
other.removeAttribute("aria-sort");
if (icon) { icon.textContent = "↕"; }
}
});
var label = button.firstChild ? button.firstChild.textContent.trim() : "this column";
if (status) { status.textContent = "Sorted by " + label + ", " + direction + "."; }
});
});
});
})();
</script>
Was this useful? Send feedback.