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>

Multi-Column Sort Table — Shift-Click Priority Sort HTML CSS JS

Multi-Column Sort Table · Tables · Plain HTML, CSS & JS · Live preview

What's included

Features

Ordered multi-key sort state
sortState is an array, not a single field, so priority order is explicit and inspectable.
Short-circuit priority comparator
The sort function only consults a lower-priority key when higher-priority keys tie.
Shift-click to append or toggle
Adds a new secondary/tertiary key, or flips direction if the column is already active.
Plain click resets to single-key
Matches the everyday expectation that a normal click means "just sort by this."
Live numbered priority badges
Each active column shows its real position in sortState, computed on every change.
Per-column direction arrows
Ascending and descending are shown independently for every active sort key.
Type-aware comparator
Numeric and string columns both sort correctly through the same generic compare function.
Stable ties fall through cleanly
Rows identical across all active keys retain their relative original order.

About this UI Snippet

Multi-Column Sort Table — Shift-Click Adds Secondary and Tertiary Sort Keys

Screenshot of the Multi-Column Sort Table snippet rendered live

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.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Rather than tracing the priority logic yourself, paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why the comparator function loops through sortState and returns on the first non-zero comparison rather than, say, averaging or combining all the comparisons at once, and why a plain click resets sortState to a single entry while shift-click either appends or toggles in place. The same assistant can help you extend it — ask it to add a way to remove one key from the middle of an active multi-sort without clearing the whole state, persist the sort configuration to a URL query parameter so a multi-sorted view is shareable, or add drag-to-reorder on the priority badges themselves so a user can re-rank which key is primary without re-clicking headers in a new order. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a "multi-column sort" data table in plain HTML, CSS, and JavaScript with no library — clicking headers sorts normally, but shift-clicking additional headers adds them as secondary, tertiary, and further sort keys rather than replacing the current sort.

Requirements:
- Store the active sort configuration as an ordered array of { key, direction } objects (not a single object), where the array's order represents sort priority — index 0 is the primary sort key, index 1 secondary, and so on.
- On a plain click (no Shift) on a column header, collapse the sort state down to a single entry for that column only, toggling its direction if it was already the sole active sort key — this matches the normal expectation that a plain click means "sort by just this column now," clearing any other active keys.
- On a Shift+click on a column header, if that column is not already in the sort state array, append it to the end (making it the lowest-priority active key so far); if it is already in the array, toggle its direction in place without changing its position in the priority order or affecting any other active key.
- Write a single comparator function that, given two rows, loops through the sort state array in order and returns the result of the first comparison that is not a tie (not zero) — only consult a lower-priority key when every higher-priority key currently being compared for those two rows is exactly equal.
- Make the comparator generic across column data types (at least string and number columns) via a per-column type field, rather than hardcoding type-specific comparison logic per column name.
- On every active sort column's header, render a small numbered badge showing its actual current priority position in the sort state array (1 for primary, 2 for secondary, etc.) plus an ascending/descending arrow indicator, both of which must update immediately whenever the sort state array changes.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSA player leaderboard renders unsorted, with sortable column headers.
  2. 2
    Click a headerThe table sorts by that column alone; clicking it again reverses direction.
  3. 3
    Shift+click a second headerThat column is added as a secondary sort key, shown with a "2" badge and its own arrow.
  4. 4
    Shift+click a third headerA tertiary key is added with a "3" badge; rows now sort by all three keys in priority order.
  5. 5
    Shift+click an active secondary key againIts direction toggles between ascending and descending without changing its priority.
  6. 6
    Click any header without ShiftAll other sort keys are cleared and that column becomes the sole primary sort.

Real-world uses

Common Use Cases

Leaderboards and rankings
Sort by region, then level, then score, mirroring how ranking systems are actually read.
Reporting and analytics tables
Order results by category and then by a metric, pair with a filterable table.
Spreadsheet-style admin tools
Bring a multi-level sort dialog's behavior directly into a web table's headers.
Scheduling and roster tables
Sort by team, then by shift, then by name for a readable multi-key roster order.
Inventory and catalog browsing
Sort by category, then stock status, then price for merchandising views.
Learning comparator composition
A clear reference for chaining tie-breaking comparators, versus a single-key sortable table.

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.