Multi-Column Sort Table — Shift-Click Priority Sort HTML CSS JS
Multi-Column Sort Table · Tables · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="tms-card">
<div class="tms-head">
<h3>Player leaderboard</h3>
<p class="tms-hint">Click a header to sort. Shift+click to add a secondary sort key.</p>
</div>
<table class="tms-table">
<thead><tr id="tmsHeadRow"></tr></thead>
<tbody id="tmsBody"></tbody>
</table>
</div>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0f1420;color:#e8ebf5;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.tms-card{background:#171d2e;border-radius:14px;padding:16px;width:100%;max-width:640px;border:1px solid #262e45;box-shadow:0 18px 44px rgba(0,0,0,.4)}
.tms-head{margin-bottom:12px}
.tms-head h3{font-size:14px;font-weight:800}
.tms-hint{font-size:11px;color:#8791b0;font-weight:600;margin-top:3px}
.tms-table{width:100%;border-collapse:collapse;font-size:12.5px}
.tms-table th{text-align:left;padding:9px 12px;color:#9aa4c4;font-weight:700;font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;border-bottom:1.5px solid #262e45;cursor:pointer;user-select:none;white-space:nowrap;position:relative}
.tms-table th:hover{color:#e8ebf5}
.tms-table td{padding:9px 12px;border-bottom:1px solid #1e2438;color:#dbe0f0}
.tms-table tbody tr:hover{background:#1c2338}
.tms-num{text-align:right;font-variant-numeric:tabular-nums}
.tms-th-inner{display:flex;align-items:center;gap:6px}
.tms-arrow{font-size:10px;color:#818cf8;width:9px;display:inline-block}
.tms-badge{background:#6366f1;color:#fff;font-size:9px;font-weight:800;border-radius:999px;width:15px;height:15px;display:inline-flex;align-items:center;justify-content:center}var COLUMNS = [
{ key: 'name', label: 'Player', type: 'string' },
{ key: 'region', label: 'Region', type: 'string' },
{ key: 'level', label: 'Level', type: 'number' },
{ key: 'wins', label: 'Wins', type: 'number' },
{ key: 'score', label: 'Score', type: 'number' },
];
var PLAYERS = [
{ name: 'NovaByte', region: 'EU', level: 42, wins: 118, score: 8820 },
{ name: 'QuietStorm', region: 'NA', level: 42, wins: 96, score: 8410 },
{ name: 'PixelRunner', region: 'APAC', level: 38, wins: 118, score: 7990 },
{ name: 'DriftKing', region: 'EU', level: 51, wins: 140, score: 9410 },
{ name: 'EchoWave', region: 'NA', level: 38, wins: 87, score: 7420 },
{ name: 'ZenithFox', region: 'APAC', level: 51, wins: 130, score: 9110 },
{ name: 'CoralBlade', region: 'EU', level: 42, wins: 96, score: 8390 },
{ name: 'MutedSignal', region: 'NA', level: 38, wins: 87, score: 7530 },
];
// Ordered list of active sort keys: [{ key, dir }], first = primary, second = secondary, etc.
var sortState = [];
function headerRow() {
document.getElementById('tmsHeadRow').innerHTML = COLUMNS.map(function (col) {
return '<th data-key="' + col.key + '"><span class="tms-th-inner">' + col.label + '<span class="tms-sort-ui" data-ui="' + col.key + '"></span></span></th>';
}).join('');
}
function updateHeaderIndicators() {
COLUMNS.forEach(function (col) {
var ui = document.querySelector('[data-ui="' + col.key + '"]');
var idx = sortState.findIndex(function (s) { return s.key === col.key; });
if (idx === -1) { ui.innerHTML = ''; return; }
var entry = sortState[idx];
var arrow = entry.dir === 'asc' ? '\u2191' : '\u2193';
ui.innerHTML = '<span class="tms-arrow">' + arrow + '</span><span class="tms-badge">' + (idx + 1) + '</span>';
});
}
function compareValues(a, b, col) {
var av = a[col.key];
var bv = b[col.key];
if (col.type === 'number') return av - bv;
return String(av).localeCompare(String(bv));
}
function sortedPlayers() {
if (!sortState.length) return PLAYERS.slice();
var rows = PLAYERS.slice();
rows.sort(function (a, b) {
// Walk sort keys in priority order; the first non-zero comparison decides the order.
for (var i = 0; i < sortState.length; i++) {
var entry = sortState[i];
var col = COLUMNS.filter(function (c) { return c.key === entry.key; })[0];
var cmp = compareValues(a, b, col);
if (entry.dir === 'desc') cmp = -cmp;
if (cmp !== 0) return cmp;
}
return 0;
});
return rows;
}
function renderBody() {
var rows = sortedPlayers();
document.getElementById('tmsBody').innerHTML = rows.map(function (p) {
return '<tr>' +
'<td>' + p.name + '</td>' +
'<td>' + p.region + '</td>' +
'<td class="tms-num">' + p.level + '</td>' +
'<td class="tms-num">' + p.wins + '</td>' +
'<td class="tms-num">' + p.score.toLocaleString() + '</td>' +
'</tr>';
}).join('');
}
function applySort(key, additive) {
var idx = sortState.findIndex(function (s) { return s.key === key; });
if (!additive) {
// Plain click: this column becomes the sole sort key, toggling direction if it already was.
if (idx === 0 && sortState.length === 1) {
sortState[0].dir = sortState[0].dir === 'asc' ? 'desc' : 'asc';
} else {
sortState = [{ key: key, dir: 'asc' }];
}
} else {
// Shift+click: add or toggle this column as an additional secondary/tertiary sort key.
if (idx === -1) {
sortState.push({ key: key, dir: 'asc' });
} else {
sortState[idx].dir = sortState[idx].dir === 'asc' ? 'desc' : 'asc';
}
}
updateHeaderIndicators();
renderBody();
}
headerRow();
document.getElementById('tmsHeadRow').addEventListener('click', function (e) {
var th = e.target.closest('th');
if (!th) return;
applySort(th.dataset.key, e.shiftKey);
});
updateHeaderIndicators();
renderBody();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Multi-Column Sort Table — Shift-Click Adds Secondary and Tertiary Sort Keys

A single-column sort answers "order by score" — but "order by region, then by level, then by score" needs multiple sort keys applied in priority order, the way spreadsheets handle a multi-level sort dialog. This snippet implements that with shift-click: a plain click sets a column as the sole sort key, while shift-clicking additional columns appends them as secondary, tertiary, and further sort keys, each shown with a small numbered priority badge and its own ascending/descending arrow.
An ordered array of sort keys, not a single field
Where a typical sortable table stores one { key, dir } pair, this one stores an *array* of them, sortState, ordered by priority — index 0 is the primary key, index 1 the secondary, and so on. That ordering is the whole feature: the comparator walks the array in sequence and only moves to the next key when the current one produces a tie, which is exactly how a multi-level spreadsheet sort behaves.
The comparator short-circuits on the first real difference
sortedPlayers()'s compare function loops through sortState in priority order, computing compareValues for each active key and returning immediately once one produces a non-zero result. Only when two rows are completely equal on the primary key does the secondary key ever get consulted, and so on down the list — the standard definition of a stable, priority-ordered multi-key sort.
Plain click resets, shift-click appends or toggles
A plain click on a header collapses sortState down to just that one column (toggling its direction if it was already the sole active sort), matching the everyday expectation that clicking a header means "sort by just this now." Shift-click instead either appends the clicked column to the end of sortState if it isn't already active, or toggles its existing direction in place if it is — so shift-clicking the same secondary column twice flips it between ascending and descending without disturbing its priority position or any other active key.
Numbered badges make priority visible, not just implied
Each active column's header shows a small circular badge with its position in sortState (1, 2, 3…) next to a direction arrow — without this, a user has no way to tell that region is being sorted before level, or that a third key even exists. The badge number is computed live from sortState.findIndex, so it always matches the column's actual current priority, updating immediately if a column is added, removed, or reordered.
A generic comparator across types
compareValues branches on each column's declared type — numeric subtraction for number columns, localeCompare for string columns — so the same multi-key loop works uniformly whether the active keys are a mix of text and numeric fields, without special-casing any particular column in the sort logic itself.
Customizing it
Add a way to remove a key from the middle of sortState (e.g. Ctrl+click), persist the sort configuration to the URL, or combine with a filterable table so multi-sort applies to a filtered subset. Compare with a plain sortable table for the single-key version this builds on.
Step by step
How to Use
- 1Paste HTML, CSS, and JSA player leaderboard renders unsorted, with sortable column headers.
- 2Click a headerThe table sorts by that column alone; clicking it again reverses direction.
- 3Shift+click a second headerThat column is added as a secondary sort key, shown with a "2" badge and its own arrow.
- 4Shift+click a third headerA tertiary key is added with a "3" badge; rows now sort by all three keys in priority order.
- 5Shift+click an active secondary key againIts direction toggles between ascending and descending without changing its priority.
- 6Click any header without ShiftAll other sort keys are cleared and that column becomes the sole primary sort.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
A single object can only represent one active sort column. Multi-level sorting needs an ordered sequence of keys where earlier entries take priority over later ones — an array preserves that order explicitly, and the comparator walks it in sequence, which a single object has no way to express.
It loops through sortState in order, computing each key's comparison result. The function returns as soon as any key produces a non-zero (non-tied) result. Only if the primary key's comparison is exactly 0 — meaning the two rows are equal on that field — does the loop proceed to check the next key in the array.
Add a modifier (e.g. Ctrl+click, or a small × on the badge) that calls sortState.splice(idx, 1) for that column's index, then re-run updateHeaderIndicators() and renderBody() — the remaining keys keep their relative order and their badge numbers recompute automatically since they're derived from array position.
Serialize sortState to a query parameter, e.g. ?sort=region.asc,level.asc,score.desc, on every applySort call, and on page load parse that parameter back into the same array shape before the first renderBody() — the sort logic itself doesn't need to change, only where sortState's initial value comes from.
Keep sortState as an array in component state and derive the sorted rows with a memoized computation (useMemo, computed, or a getter) that re-runs the same short-circuit comparator whenever sortState or the source data changes; bind each header's click handler to call your state setter with the updated array using the same additive/reset logic.