← Charts

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.

PlanetMoonsWidth (km)Day (h)
Mercury04,8794,223
Venus012,1042,802
Earth112,74224
Mars26,77925
Jupiter95139,82010
Saturn146116,46011
Uranus2850,72417
Neptune1649,24416

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-table-sort-reorder to a <table> with a <thead> and a <tbody>.
  3. 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-sortOn a cell: the value to sort by, when it differs from what’s shown.
data-nosortOn a heading: leave that column unsortable.
data-durationTime for the rows to slide. Default 600 ms.
data-staggerGap between rows starting to move. Default 25 ms.
data-autoplaySort 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’).

1 · Snippet
<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>
2 · Your HTML
<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>