← Numbers

Rank Change

A rank number rolls to its new place digit by digit, like an odometer, with a badge beside it: a green up arrow and the places climbed, or a red down arrow when it slips. Great for leaderboards and league tables.

YYouWeekly league12
SSamWeekly league9

How to use it

  1. Paste the snippet just before </head>.
  2. Put the rank in an element with data-rank-change and the rank it moves from in data-from. It rolls when scrolled into view.

Options

Add any of these attributes to the same element.

data-fromThe rank it rolls from.
data-values="A|B|C"Several ranks to move between in turn.
data-holdHow long each rank stays, with data-values. Default 2200 ms.
data-prefixText before the number. Default #.
data-durationHow long the digits roll. Default 1100 ms.

Climbing (a smaller number) rolls upwards in green; slipping rolls down in red. Move it from a script with el._rkc.set(5).

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Rank Change: a rank number rolls to its new place, digit by digit like an
    // odometer, with a badge beside it: a green up arrow and how many places it
    // climbed, or a red down arrow when it slipped.
    // Usage: <span data-rank-change data-from="18">12</span>
    // Optional: data-from (the rank it rolls from when scrolled into view),
    // data-values (several ranks to move between in turn, e.g. "12|8|9|3"),
    // data-hold (time on each, default 2200 ms), data-prefix (before the
    // number, default "#"), data-duration (roll time, default 1100 ms).
    // Script use: el._rkc.set(5)
    var DEFAULTS = { prefix: '#', hold: 2200, duration: 1100 };
    var ARROW = '<svg viewBox="0 0 12 12" aria-hidden="true"><path d="M6 2.2 10.4 8.6H1.6Z" fill="currentColor"/></svg>';

    var css =
      '.rkc{display:inline-flex;align-items:center;gap:0.3em;font-variant-numeric:tabular-nums;white-space:nowrap}' +
      '.rkc-num{display:inline-flex;transition:color .5s}' +
      '.rkc-num.rkc-up{color:#16a34a}' +
      '.rkc-num.rkc-down{color:#dc2626}' +
      '.rkc-slot{display:inline-block;height:1.15em;line-height:1.15em;overflow:hidden}' +
      '.rkc-strip{display:block}' +
      '.rkc-strip>span{display:block;height:1.15em}' +
      '.rkc-rolling{-webkit-mask-image:linear-gradient(transparent,#000 22%,#000 78%,transparent);mask-image:linear-gradient(transparent,#000 22%,#000 78%,transparent)}' +
      '.rkc-badge{display:inline-flex;align-items:center;gap:0.15em;font-size:0.36em;font-weight:800;letter-spacing:0;padding:0.3em 0.6em 0.3em 0.45em;border-radius:999px;' +
      'background:rgba(15,15,30,.07);color:rgba(15,15,30,.55);transition:background-color .35s,color .35s}' +
      '.rkc-badge svg{width:1em;height:1em;transition:transform .35s cubic-bezier(.3,1.5,.5,1)}' +
      '.rkc-badge.rkc-up{background:#dcfce7;color:#15803d}' +
      '.rkc-badge.rkc-down{background:#fee2e2;color:#b91c1c}' +
      '.rkc-badge.rkc-down svg{transform:rotate(180deg)}' +
      '.rkc-badge.rkc-same svg{transform:rotate(90deg)}' +
      '.rkc-measure{position:absolute;visibility:hidden;white-space:pre}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function make(tag, cls, parent, text) {
      var n = document.createElement(tag);
      if (cls) n.className = cls;
      if (text !== undefined) n.textContent = text;
      parent.appendChild(n);
      return n;
    }

    function width(r, ch) {
      if (!ch) return 0;
      r.measure.textContent = ch;
      return r.measure.getBoundingClientRect().width;
    }

    function still(r, n) {
      r.num.textContent = '';
      var s = r.prefix + n;
      s.split('').forEach(function (ch) { make('span', 'rkc-strip', make('span', 'rkc-slot', r.num), ch); });
    }

    function setup(el) {
      var d = el.dataset;
      var values = d.values ? d.values.split('|').map(function (v) { return parseInt(v, 10); }).filter(function (v) { return v > 0; }) : [];
      var target = parseInt(el.textContent.replace(/[^\d]/g, ''), 10) || values[0] || 1;
      var r = {
        el: el, prefix: d.prefix !== undefined ? d.prefix : DEFAULTS.prefix, values: values, index: 0,
        from: parseInt(d.from, 10) || 0, target: values[0] || target, rank: 0,
        hold: parseFloat(d.hold) || DEFAULTS.hold, duration: parseFloat(d.duration) || DEFAULTS.duration, anims: []
      };
      el.classList.add('rkc');
      el.textContent = '';
      r.num = make('span', 'rkc-num', el);
      r.num.setAttribute('aria-hidden', 'true');
      r.badge = make('span', 'rkc-badge rkc-same', el);
      r.badge.setAttribute('aria-hidden', 'true');
      r.badge.innerHTML = ARROW + '<b></b>';
      r.delta = r.badge.querySelector('b');
      r.measure = make('span', 'rkc-measure', el);
      r.rank = r.from || r.target;
      still(r, r.rank);
      r.delta.textContent = '–';
      label(r, 0);
      r.set = function (n) { go(r, n); };
      return r;
    }

    function label(r, moved) {
      r.el.setAttribute('aria-label', 'Rank ' + r.rank + (moved > 0 ? ', up ' + moved : moved < 0 ? ', down ' + (-moved) : ''));
    }

    function go(r, next) {
      var prev = r.rank;
      if (next === prev) return;
      r.anims.forEach(function (a) { a.pause(); });
      r.anims = [];
      r.rank = next;
      var moved = prev - next;   // positive = climbed
      var cls = moved > 0 ? 'rkc-up' : 'rkc-down';
      r.badge.className = 'rkc-badge ' + cls;
      r.delta.textContent = Math.abs(moved);
      label(r, moved);
      if (reduced()) { still(r, next); return; }

      r.num.className = 'rkc-num ' + cls;
      clearTimeout(r.cool);
      r.cool = setTimeout(function () { r.num.className = 'rkc-num'; }, 1400);
      anime.animate(r.badge, { scale: [0.5, 1], duration: 600, ease: 'outBack(2.5)' });
      anime.animate(r.badge.querySelector('svg'), { y: moved > 0 ? [5, 0] : [-5, 0], duration: 500, delay: 150, ease: 'outBack(3)' });

      // Right-align both numbers and roll every digit that differs, counting
      // the way the rank moved. Climbing rolls upwards, slipping rolls down.
      var a = String(prev).split(''), b = String(next).split('');
      while (a.length < b.length) a.unshift('');
      while (b.length < a.length) b.unshift('');
      var counting = next > prev ? 1 : 9;
      var rise = moved > 0;
      r.num.textContent = '';
      if (r.prefix) make('span', 'rkc-strip', make('span', 'rkc-slot', r.num), r.prefix);
      var left = 0;
      a.forEach(function (x, i) {
        var y = b[i];
        var slot = make('span', 'rkc-slot', r.num);
        var strip = make('span', 'rkc-strip', slot);
        if (x === y) { strip.textContent = x; return; }
        var seq = [x];
        if (x && y) { var n = +x; while (String(n) !== y) { n = (n + counting) % 10; seq.push(String(n)); } }
        else seq.push(y);
        if (!rise) seq.reverse();
        seq.forEach(function (ch) { make('span', '', strip, ch); });
        slot.classList.add('rkc-rolling');
        left++;
        var span = (seq.length - 1) * 1.15;
        var from = rise ? 0 : -span, to = rise ? -span : 0;
        var dur = r.duration + i * 90 + Math.min(seq.length, 10) * 20;
        anime.utils.set(strip, { y: from + 'em' });
        r.anims.push(anime.animate(strip, { y: to + 'em', duration: Math.max(500, dur), ease: 'outBack(1.1)' }));
        r.anims.push(anime.animate(slot, { width: [width(r, x), width(r, y)], duration: dur * 0.7, ease: 'inOut(2)',
          onComplete: function () { if (--left === 0 && r.rank === next) setTimeout(function () { if (r.rank === next) still(r, next); }, 700); }
        }));
      });
    }

    function cycle(r) {
      setTimeout(function () {
        if (r.visible) {
          r.index = (r.index + 1) % r.values.length;
          go(r, r.values[r.index]);
        }
        cycle(r);
      }, r.hold);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var r = e.target._rkc;
          r.visible = e.isIntersecting;
          if (!r.visible || r.started) return;
          r.started = true;
          if (r.from && r.from !== r.target) setTimeout(function () { go(r, r.target); }, 300);
          if (r.values.length > 1) cycle(r);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-rank-change]').forEach(function (el) {
        if (el._rkc) return;
        el._rkc = setup(el);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<p>Your rank: <span data-rank-change data-from="24">12</span></p>