Tally Marks
Hand-drawn tally marks scratch in one at a time as the count grows, and every fifth mark strikes diagonally through the four before it. Each stroke wobbles a little, as if drawn by hand, and the count can sit underneath.
Days without a bug
How to use it
- Paste the snippet just before
</head>. - Add
data-tally-marks="23"(the count) to an empty element. Size it withfont-size; each group of five is 2.6em wide.
Options
Add any of these attributes to the same element.
data-from | Where the count starts. Default 0. |
data-interval | Time between marks. Default 260 ms. |
data-loop | Rub the marks out and count again. |
data-hold | How long the full count stays before looping. Default 2600 ms. |
data-label | Show the count under the marks, followed by this text, e.g. days. |
data-width | Stroke width. Default 4.5. |
data-color | Mark colour. Default the text colour. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Tally Marks: hand-drawn tally marks scratch in one at a time as the count
// grows, every fifth struck diagonally through the four before it.
// Usage: <div data-tally-marks="23"></div> (the count to reach)
// Optional: data-from (where it starts, default 0), data-interval (ms per
// mark, default 260), data-loop (rub out and count again), data-hold (ms the
// full count stays before looping, default 2600), data-label (text after
// the number shown under the marks, e.g. "days"), data-width (stroke width,
// default 4.5), data-color (default the text colour).
// Size it with font-size: each group of five is 2.6em wide.
var DEFAULTS = { from: 0, interval: 260, hold: 2600, width: 4.5 };
var css =
'.tlm{display:inline-flex;flex-direction:column;align-items:center;gap:.5em;max-width:100%}' +
'.tlm-marks{display:flex;flex-wrap:wrap;justify-content:center;gap:.35em .7em;max-width:100%}' +
'.tlm-group{width:2.6em;height:2.6em;overflow:visible;flex:none}' +
'.tlm-group path{visibility:hidden;fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round}' +
'.tlm-count{font-weight:800;font-size:1em;letter-spacing:-.01em;font-variant-numeric:tabular-nums}' +
'.tlm-count b{font-size:1.5em;margin-right:.25em}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function j(n) { return (Math.random() * 2 - 1) * n; }
function f(n) { return n.toFixed(1); }
// One wobbly stroke as a gentle curve between two points.
function stroke(x1, y1, x2, y2, bow) {
var mx = (x1 + x2) / 2 + j(bow), my = (y1 + y2) / 2 + j(bow);
return 'M' + f(x1) + ' ' + f(y1) + 'Q' + f(mx) + ' ' + f(my) + ' ' + f(x2) + ' ' + f(y2);
}
function group(T) {
var paths = '';
for (var i = 0; i < 4; i++) {
var x = 12 + i * 12 + j(1.5);
paths += '<path pathLength="1" stroke-dasharray="1" stroke-dashoffset="1" stroke-width="' + T.width + '" d="' + stroke(x + j(1.5), 6 + j(2.5), x + j(1.5), 54 + j(2.5), 2.2) + '"/>';
}
paths += '<path pathLength="1" stroke-dasharray="1" stroke-dashoffset="1" stroke-width="' + T.width + '" d="' + stroke(2 + j(2), 42 + j(3), 58 + j(2), 16 + j(3), 3) + '"/>';
var g = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
g.setAttribute('class', 'tlm-group');
g.setAttribute('viewBox', '0 0 60 60');
g.innerHTML = paths;
return g;
}
function setup(el) {
var d = el.dataset;
var T = {
el: el, target: Math.max(0, Math.round(+el.getAttribute('data-tally-marks') || 0)), from: Math.max(0, +d.from || DEFAULTS.from),
interval: +d.interval || DEFAULTS.interval, hold: +d.hold || DEFAULTS.hold, width: +d.width || DEFAULTS.width,
label: d.label || '', loop: d.loop !== undefined && d.loop !== 'false', still: reduced(),
visible: false, timer: 0, count: 0, marks: []
};
el.classList.add('tlm');
if (d.color) el.style.color = d.color;
el.innerHTML = '<div class="tlm-marks" aria-hidden="true"></div>' + (d.label !== undefined ? '<div class="tlm-count" aria-hidden="true"><b>0</b>' + T.label + '</div>' : '');
T.box = el.querySelector('.tlm-marks');
T.num = el.querySelector('.tlm-count b');
el.setAttribute('role', 'img');
el.setAttribute('aria-label', 'Tally: ' + T.target + (T.label ? ' ' + T.label : ''));
build(T);
reach(T, T.still ? T.target : T.from, false);
return T;
}
// Fresh groups (with new wobbles) for every round.
function build(T) {
T.box.innerHTML = '';
T.marks = [];
for (var g = 0; g < Math.ceil(T.target / 5); g++) {
var svg = group(T);
T.box.appendChild(svg);
T.marks = T.marks.concat([].slice.call(svg.querySelectorAll('path')));
}
T.count = 0;
}
function reach(T, n, animate) {
for (var i = T.count; i < n; i++) {
var p = T.marks[i], cross = i % 5 === 4;
p.style.visibility = 'visible';
if (animate) anime.animate(p, { strokeDashoffset: [1, 0], duration: cross ? 300 : 190, ease: 'out(2)' });
else p.setAttribute('stroke-dashoffset', 0);
}
T.count = n;
if (T.num) {
T.num.textContent = n;
if (animate) anime.animate(T.num, { scale: [1.25, 1], duration: 300, ease: 'out(2)' });
}
}
function tick(T) {
if (!T.visible) return;
if (T.count >= T.target) {
if (T.loop) T.timer = setTimeout(rubOut.bind(null, T), T.hold);
return;
}
reach(T, T.count + 1, true);
// A beat longer before the strike-through, like a hand pausing.
T.timer = setTimeout(function () { tick(T); }, T.count % 5 === 4 ? T.interval * 1.6 : T.interval);
}
function rubOut(T) {
if (!T.visible) return;
anime.animate(T.box, {
opacity: [1, 0], x: [0, 6], duration: 450, ease: 'in(2)',
onComplete: function () {
build(T);
reach(T, T.from, false);
anime.utils.set(T.box, { opacity: 1, x: 0 });
T.timer = setTimeout(function () { tick(T); }, 500);
}
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var T = e.target._tlm;
T.visible = e.isIntersecting;
clearTimeout(T.timer);
if (T.visible) T.timer = setTimeout(function () { tick(T); }, 350);
});
}, { threshold: 0.25 });
document.querySelectorAll('[data-tally-marks]').forEach(function (el) {
if (el._tlm) return;
var T = (el._tlm = setup(el));
if (!T.still) io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-tally-marks="23" data-label="days"></div>
<!-- chalk on a board: -->
<div data-tally-marks="12" data-color="#f4f1e8" style="background: #1f3b2d; font-size: 32px"></div>