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
- Paste the snippet just before
</head>. - Put the rank in an element with
data-rank-changeand the rank it moves from indata-from. It rolls when scrolled into view.
Options
Add any of these attributes to the same element.
data-from | The rank it rolls from. |
data-values="A|B|C" | Several ranks to move between in turn. |
data-hold | How long each rank stays, with data-values. Default 2200 ms. |
data-prefix | Text before the number. Default #. |
data-duration | How 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).
<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><p>Your rank: <span data-rank-change data-from="24">12</span></p>