Table Sort Reorder
Click a column heading to sort an ordinary table, and the rows slide to their new places instead of jumping, so you can follow each one. The sorted column lights up for a moment and an arrow shows the direction; click again to reverse it.
| Planet | Moons | Width (km) | Day (h) |
|---|---|---|---|
| Mercury | 0 | 4,879 | 4,223 |
| Venus | 0 | 12,104 | 2,802 |
| Earth | 1 | 12,742 | 24 |
| Mars | 2 | 6,779 | 25 |
| Jupiter | 95 | 139,820 | 10 |
| Saturn | 146 | 116,460 | 11 |
| Uranus | 28 | 50,724 | 17 |
| Neptune | 16 | 49,244 | 16 |
How to use it
- Paste the snippet just before
</head>. - Add
data-table-sort-reorderto a<table>with a<thead>and a<tbody>. - Columns of numbers sort as numbers, ignoring commas and units, and the rest as text.
Options
Add any of these attributes to the same element.
data-sort | On a cell: the value to sort by, when it differs from what’s shown. |
data-nosort | On a heading: leave that column unsortable. |
data-duration | Time for the rows to slide. Default 600 ms. |
data-stagger | Gap between rows starting to move. Default 25 ms. |
data-autoplay | Sort by each column in turn until someone clicks a heading. |
Headings become real buttons and the table gets aria-sort, so it works from the keyboard and with screen readers. Row backgrounds default to white; set --tso-bg to match your page. From a script: animacious.tableSortReorder(table, 1, ‘desc’).
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Table Sort Reorder: click a column heading to sort an ordinary table,
// and the rows slide to their new places instead of jumping, with the
// sorted column briefly lit. Click again to reverse the order. With
// data-autoplay it sorts by each column in turn until someone clicks.
// Usage:
// <table data-table-sort-reorder>
// <thead><tr><th>Planet</th><th>Moons</th></tr></thead>
// <tbody>
// <tr><td>Mars</td><td>2</td></tr>
// <tr><td>Earth</td><td>1</td></tr>
// </tbody>
// </table>
// Columns whose cells are all numbers sort as numbers (commas and units
// are ignored), the rest as text. Put data-sort on a cell to sort by a
// different value from the one shown, and data-nosort on a heading to
// leave that column alone.
// Optional: data-duration (ms for the rows to slide, default 600),
// data-stagger (gap between rows, default 25), data-autoplay (sort by
// each column in turn until someone clicks a heading).
// Script use: animacious.tableSortReorder(table, columnIndex, 'asc'|'desc').
var DEFAULTS = { duration: 600, stagger: 25, every: 2400 };
var css =
'.tso{border-collapse:separate;border-spacing:0;font-variant-numeric:tabular-nums}' +
'.tso th{text-align:left;font-weight:700;white-space:nowrap;border-bottom:2px solid rgba(127,127,127,.25);padding:0}' +
'.tso th.tso-num,.tso td.tso-num{text-align:right}' +
'.tso-btn{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:5px;width:100%;padding:.55em .7em;cursor:pointer;border-radius:6px 6px 0 0}' +
'.tso th.tso-num .tso-btn{justify-content:flex-end}' +
'.tso-btn:focus-visible{outline:2px solid #6a0dad;outline-offset:-2px}' +
'.tso-btn:hover{background:rgba(127,127,127,.08)}' +
'.tso-arrow{display:inline-block;width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;' +
'border-bottom:5px solid currentColor;opacity:.25;transition:transform .3s,opacity .3s}' +
'.tso th[aria-sort] .tso-btn{color:#6a0dad}' +
'.tso th[aria-sort] .tso-arrow{opacity:1}' +
'.tso th[aria-sort="descending"] .tso-arrow{transform:rotate(180deg)}' +
'.tso tbody tr{position:relative;background:var(--tso-bg,#fff)}' +
'.tso td{text-align:left;padding:.5em .7em;border-bottom:1px solid rgba(127,127,127,.15);white-space:nowrap;transition:background-color .5s}' +
'.tso td.tso-on{background-color:rgba(168,85,247,.12);font-weight:700}' +
'.tso tbody tr.tso-moving{z-index:1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(s) {
var t = String(s).replace(/[^0-9.\-]/g, '');
return t === '' || t === '-' || t === '.' ? NaN : parseFloat(t);
}
function key(td) { return td.hasAttribute('data-sort') ? td.getAttribute('data-sort') : td.textContent.trim(); }
function setup(table) {
var d = table.dataset;
var thumb = !!table.closest('[aria-hidden="true"]');
var head = table.tHead && table.tHead.rows[0];
var body = table.tBodies[0];
if (!head || !body) return null;
table.classList.add('tso');
var c = { table: table, body: body, cols: [], col: -1, dir: 'asc', autoplay: d.autoplay !== undefined && d.autoplay !== 'false' };
Array.prototype.forEach.call(head.cells, function (th, i) {
var rows = Array.prototype.slice.call(body.rows);
var numeric = rows.length > 0 && rows.every(function (r) { return r.cells[i] && !isNaN(num(key(r.cells[i]))); });
var col = { th: th, i: i, numeric: numeric, sortable: !th.hasAttribute('data-nosort') };
c.cols.push(col);
if (numeric) {
th.classList.add('tso-num');
rows.forEach(function (r) { if (r.cells[i]) r.cells[i].classList.add('tso-num'); });
}
if (!col.sortable) return;
var label = th.innerHTML;
var btn = document.createElement(thumb ? 'span' : 'button');
if (!thumb) btn.type = 'button';
btn.className = 'tso-btn';
btn.innerHTML = '<span>' + label + '</span><span class="tso-arrow" aria-hidden="true"></span>';
th.innerHTML = '';
th.appendChild(btn);
btn.addEventListener('click', function () {
c.user = true;
sort(c, i, c.col === i ? (c.dir === 'asc' ? 'desc' : 'asc') : (col.numeric ? 'desc' : 'asc'));
});
});
table._tso = c;
return c;
}
function sort(c, i, dir) {
var col = c.cols[i];
if (!col) return;
var rows = Array.prototype.slice.call(c.body.rows);
var first = rows.map(function (r) { return r.getBoundingClientRect().top; });
var sorted = rows.slice().sort(function (a, b) {
var x = key(a.cells[i]), y = key(b.cells[i]), r;
r = col.numeric ? num(x) - num(y) : x.localeCompare(y, undefined, { numeric: true, sensitivity: 'base' });
return dir === 'asc' ? r : -r;
});
c.col = i; c.dir = dir;
c.cols.forEach(function (o) {
if (o.i === i) o.th.setAttribute('aria-sort', dir === 'asc' ? 'ascending' : 'descending');
else o.th.removeAttribute('aria-sort');
});
sorted.forEach(function (r) { c.body.appendChild(r); });
// Light the sorted column for a moment.
rows.forEach(function (r) {
Array.prototype.forEach.call(r.cells, function (td, j) { td.classList.toggle('tso-on', j === i); });
});
clearTimeout(c.unlight);
c.unlight = setTimeout(function () {
rows.forEach(function (r) { if (r.cells[i]) r.cells[i].classList.remove('tso-on'); });
}, 1400);
if (reduced()) return;
var d = c.table.dataset;
var dur = +d.duration || DEFAULTS.duration, stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
var n = 0;
// Measure where each row now sits without any slide still in progress.
sorted.forEach(function (r) { anime.utils.remove(r); r.style.transform = ''; });
sorted.forEach(function (r) {
var dy = first[rows.indexOf(r)] - r.getBoundingClientRect().top;
if (Math.abs(dy) < 1) return;
r.classList.add('tso-moving');
anime.animate(r, {
y: [dy, 0], duration: dur, delay: n++ * stagger, ease: 'inOut(3)',
onComplete: function () { r.classList.remove('tso-moving'); r.style.transform = ''; }
});
});
}
// Autoplay: each sortable column in turn, numbers biggest first.
function step(c) {
c.timer = 0;
if (c.user) return;
if (c.visible) {
var list = c.cols.filter(function (o) { return o.sortable; });
if (!list.length) return;
var at = list.map(function (o) { return o.i; }).indexOf(c.col);
var next = list[(at + 1) % list.length];
sort(c, next.i, next.numeric ? 'desc' : 'asc');
}
c.timer = setTimeout(function () { step(c); }, DEFAULTS.every);
}
window.animacious = window.animacious || {};
window.animacious.tableSortReorder = function (table, i, dir) {
var c = table._tso;
if (c) sort(c, i, dir || (c.col === i && c.dir === 'asc' ? 'desc' : 'asc'));
};
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var c = e.target._tso;
c.visible = e.isIntersecting;
if (c.visible && c.autoplay && !c.timer && !c.user) c.timer = setTimeout(function () { step(c); }, 900);
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-table-sort-reorder]').forEach(function (table) {
if (table._tso) return;
var c = setup(table);
if (!c) return;
if (c.autoplay && !reduced()) io.observe(table);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><table data-table-sort-reorder>
<thead><tr><th>Planet</th><th>Moons</th><th>Width (km)</th></tr></thead>
<tbody>
<tr><td>Venus</td><td>0</td><td>12,104</td></tr>
<tr><td>Earth</td><td>1</td><td>12,742</td></tr>
<tr><td>Mars</td><td>2</td><td>6,779</td></tr>
<tr><td>Jupiter</td><td>95</td><td>139,820</td></tr>
</tbody>
</table>