Abacus Counter
A wooden abacus with one rod per digit. Beads slide along the rods to show a number, units first and then each carry up the rods, while the number is written underneath. Move between several numbers or let it count up bead by bead.
How to use it
- Paste the snippet just before
</head>. - Add
data-abacus-counter="1250"(the number) to an empty element. Size it withfont-size; the frame is about 20em wide.
Options
Add any of these attributes to the same element.
data-values="A|B|C" | Several numbers to move between in turn. |
data-hold | How long each number stays. Default 2200 ms. |
data-count | Count up from the number, bead by bead. |
data-step / data-interval | With data-count: how much to add and how often. Default 1 every 900 ms. |
data-rods | How many rods. Default enough for the largest number, at least 3. |
data-loop | With a single number: clear the beads and slide them in again. |
data-hide-value | No number underneath. |
data-colors | Comma-separated bead colours, one per rod from the top. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Abacus Counter: a wooden abacus whose beads slide along the rods to show
// a number, one rod per digit, with the number written underneath. Give it
// several numbers to move between, or let it count up bead by bead.
// Usage: <div data-abacus-counter="1250"></div>
// Optional: data-values="1250|3048|967" (numbers to move between in turn),
// data-hold (ms each number stays, default 2200), data-count (count up from
// the number, data-step at a time, every data-interval ms: defaults 1 and
// 900), data-rods (how many rods, default enough for the largest number,
// at least 3), data-loop (with a single number: clear and slide in again),
// data-hide-value (no number underneath), data-colors (bead colours, one
// per rod from the top, comma-separated).
// Size it with font-size: the frame is about 20em wide.
var DEFAULTS = { hold: 2200, step: 1, interval: 900, colors: '#ef4444,#f59e0b,#22b35e,#2f7bf5,#9b4dea,#ec4899' };
var BW = 1.1, TRACK = 15.4; // bead width and rod length, in em
var css =
'.abk{display:inline-flex;flex-direction:column;align-items:center;gap:.7em;font-variant-numeric:tabular-nums;max-width:100%}' +
'.abk-frame{position:relative;display:grid;gap:.25em;padding:.55em .7em;border-radius:.6em;' +
'background:linear-gradient(#f6ead7,#efdcc0);box-shadow:inset 0 0 0 .42em #9a5b2e,inset 0 0 0 .5em #7a4322,0 .4em 1em rgba(60,30,10,.2)}' +
'.abk-row{display:flex;align-items:center;gap:.4em;padding:0 .2em}' +
'.abk-place{width:2.2em;text-align:right;font-size:.6em;font-weight:700;color:#7a4322;opacity:.8}' +
'.abk-rod{position:relative;width:' + TRACK + 'em;height:1.3em}' +
'.abk-rod::before{content:"";position:absolute;left:-.2em;right:-.2em;top:50%;height:.14em;margin-top:-.07em;border-radius:.1em;background:linear-gradient(#b8bcc6,#7d828e)}' +
'.abk-track{position:absolute;left:0;top:0;bottom:0;right:' + BW + 'em}' +
'.abk-bead{position:absolute;top:.05em;width:' + BW + 'em;height:1.2em;border-radius:45%/50%;' +
'background:radial-gradient(ellipse at 40% 30%,rgba(255,255,255,.6),rgba(255,255,255,0) 45%),linear-gradient(90deg,rgba(0,0,0,.25),rgba(0,0,0,0) 30%,rgba(0,0,0,0) 70%,rgba(0,0,0,.25)),var(--abk-c);' +
'box-shadow:0 .08em .12em rgba(0,0,0,.25)}' +
'.abk-digit{width:1em;font-size:.9em;font-weight:800;color:#5c3218;text-align:center}' +
'.abk-total{font-size:1.8em;font-weight:800;letter-spacing:-.02em;line-height:1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
var NAMES = ['1', '10', '100', '1K', '10K', '100K', '1M', '10M', '100M'];
function setup(el) {
var d = el.dataset;
var values = (d.values || el.getAttribute('data-abacus-counter') || '0').split('|').map(function (v) { return Math.max(0, Math.round(+v) || 0); });
var biggest = Math.max.apply(null, values.concat(d.count !== undefined ? [values[0] * 10 + 9] : []));
var A = {
el: el, values: values, index: 0, hold: +d.hold || DEFAULTS.hold, count: d.count !== undefined,
step: +d.step || DEFAULTS.step, interval: +d.interval || DEFAULTS.interval,
rods: Math.max(3, Math.min(9, parseInt(d.rods, 10) || String(biggest).length)),
loop: d.loop !== undefined && d.loop !== 'false', still: reduced(), visible: false, timer: 0, value: 0, digits: []
};
var colors = (d.colors || DEFAULTS.colors).split(',');
el.classList.add('abk');
var rows = '';
for (var r = A.rods - 1; r >= 0; r--) {
var beads = '';
for (var b = 0; b < 10; b++) beads += '<span class="abk-bead"></span>';
rows += '<div class="abk-row" style="--abk-c:' + colors[(A.rods - 1 - r) % colors.length] + '"><span class="abk-place">' + NAMES[r] + '</span>' +
'<span class="abk-rod"><span class="abk-track">' + beads + '</span></span><span class="abk-digit">0</span></div>';
}
el.innerHTML = '<div class="abk-frame" aria-hidden="true">' + rows + '</div>' + (d.hideValue !== undefined ? '' : '<div class="abk-total" aria-hidden="true">0</div>');
A.rows = [].slice.call(el.querySelectorAll('.abk-row')).reverse().map(function (row) { // rows[0] is the units
return { beads: [].slice.call(row.querySelectorAll('.abk-bead')), digit: row.querySelector('.abk-digit'), value: -1 };
});
A.total = el.querySelector('.abk-total');
el.setAttribute('role', 'img');
A.max = Math.pow(10, A.rods) - 1;
set(A, A.still ? values[0] : 0, false);
return A;
}
// Where bead b sits for digit n: counted beads slide left, the rest wait on the right.
function pos(b, n) {
var em = b < n ? b * BW : TRACK - (10 - b) * BW;
return (em / (TRACK - BW) * 100).toFixed(3) + '%';
}
function set(A, v, animate) {
v = Math.max(0, Math.min(A.max, Math.round(v)));
var from = A.value, k = 0;
A.value = v;
A.rows.forEach(function (row, r) {
var n = Math.floor(v / Math.pow(10, r)) % 10;
if (n === row.value) return;
var old = row.value;
row.value = n;
row.digit.textContent = n;
var moving = row.beads.filter(function (bead, b) { return old < 0 || (b < n) !== (b < old); });
if (animate) {
// Units first, then each carry up the rods.
anime.animate(moving, { left: function (bead) { return pos(row.beads.indexOf(bead), n); }, duration: 520, delay: k * 110, ease: 'out(3)' });
anime.animate(row.digit, { scale: [1.5, 1], duration: 400, delay: k * 110, ease: 'out(2)' });
k++;
} else moving.forEach(function (bead) { bead.style.left = pos(row.beads.indexOf(bead), n); });
});
if (A.total) {
if (animate) {
var st = { v: from };
anime.animate(st, { v: v, duration: 500 + k * 110, ease: 'out(2)', onUpdate: function () { A.total.textContent = Math.round(st.v).toLocaleString('en-GB'); } });
} else A.total.textContent = v.toLocaleString('en-GB');
}
A.el.setAttribute('aria-label', 'Abacus showing ' + v);
}
function next(A) {
if (!A.visible) return;
var delay = A.hold;
if (A.count) {
set(A, A.value === 0 && !A.begun ? A.values[0] : (A.value + A.step > A.max ? 0 : A.value + A.step), true);
delay = A.interval;
} else if (A.values.length > 1) {
set(A, A.values[A.index++ % A.values.length], true);
} else if (!A.begun || A.loop) {
var target = A.values[0];
if (A.value === target && A.loop) { set(A, 0, true); delay = 900; }
else set(A, target, true);
if (!A.loop) { A.begun = true; return; }
}
A.begun = true;
A.timer = setTimeout(function () { next(A); }, delay);
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var A = e.target._abk;
A.visible = e.isIntersecting;
clearTimeout(A.timer);
if (A.visible) A.timer = setTimeout(function () { next(A); }, A.begun ? 600 : 300);
});
}, { threshold: 0.25 });
document.querySelectorAll('[data-abacus-counter]').forEach(function (el) {
if (el._abk) return;
var A = (el._abk = setup(el));
if (!A.still) io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-abacus-counter="1250"></div>
<!-- counting up a rod at a time: -->
<div data-abacus-counter="95" data-count data-rods="3" style="font-size: 18px"></div>