Turn any table sortable in 20 lines
Creem.io
Merchant of record payments for SaaS and digital products, handling checkout, subscriptions, taxes, invoices, and global payment operations.
Infrastructure for Coding Agents
Zerops ZCP lets you deploy an AI-ready environment, connect agents like Claude Code or Codex, and build directly inside your live cloud workspace.
Start from a recipe, describe the change you want, and let the agent build, deploy, and verify it.
Someone asks “can you make this sortable?” and suddenly you’re evaluating data-grid libraries with 400 config options. For a plain HTML table, sorting is 20 lines of vanilla JavaScript: sort the rows, re-append them, done.
The trick that makes it clean: appending an element that’s already in the DOM moves it. No cloning, no innerHTML, no re-rendering.
The fix
function sortTable(th) {
const tbody = th.closest('table').querySelector('tbody');
const index = [...th.parentElement.children].indexOf(th);
const asc = th.dataset.dir !== 'asc';
th.dataset.dir = asc ? 'asc' : 'desc';
[...tbody.rows]
.sort((a, b) => {
const x = a.cells[index].textContent.trim();
const y = b.cells[index].textContent.trim();
const nx = parseFloat(x.replace(/[^0-9.-]/g, ''));
const ny = parseFloat(y.replace(/[^0-9.-]/g, ''));
const diff = Number.isNaN(nx) || Number.isNaN(ny)
? x.localeCompare(y)
: nx - ny;
return asc ? diff : -diff;
})
.forEach((row) => tbody.append(row));
}
document.querySelectorAll('th').forEach((th) => {
th.addEventListener('click', () => sortTable(th));
});
Click any header to sort. Click again to reverse. Works on any table with a <thead> and <tbody>, including markup you don’t control.
Try the live demo
| Customer | Orders | Total | Country |
|---|---|---|---|
| Ana Silva | 12 | $840 | Portugal |
| Jonas Weber | 3 | $1,275 | Germany |
| Mila Todorovic | 27 | $96 | Serbia |
| Liam Byrne | 8 | $430 | Ireland |
| Sofia Rossi | 19 | $2,110 | Italy |
Click a header to sort. Click again to reverse.
Where this comes up
- Admin panels: users, orders, invoices. Every one of these tables gets the “make it sortable” request eventually.
- CMS output: the table comes from WordPress or a Markdown renderer, you can’t change the markup, but you can attach this.
- Internal dashboards: too small to justify a data grid, too big to read unsorted.
The values, explained
The replace strips everything that isn’t part of a number, so “$1,275” becomes 1275 and “12” becomes 12. If either cell has no number in it (names, countries), parseFloat returns NaN and the comparator falls back to localeCompare for a proper text sort:
'$1,275'.replace(/[^0-9.-]/g, ''); // '1275'
parseFloat('1275'); // 1275
parseFloat(''); // NaN, so text columns use localeCompare
Don’t reach for plain string comparison on numbers, it sorts “10” before “9”. Parsing first is what makes currency, percentages, and plain integers all sort correctly with the same code.
th.dataset.dir stores the current direction on the header itself, which means it doubles as a styling hook for the arrow:
th[data-dir='asc']::after { content: ' ↑'; }
th[data-dir='desc']::after { content: ' ↓'; }
Why this matters
- No library, no config: a data grid is the right call for virtual scrolling and editing, not for sorting.
- Works anywhere: it reads the table that’s already rendered, whatever produced it.
- Users stop exporting to Excel: the number one reason people export a table is to sort it.
Find one table your users squint at, paste this in, and ship it. Total cost: 20 lines and one CSS arrow.
Happy coding!
Marko