← Numbers

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

  1. Paste the snippet just before </head>.
  2. Add data-tally-marks="23" (the count) to an empty element. Size it with font-size; each group of five is 2.6em wide.

Options

Add any of these attributes to the same element.

data-fromWhere the count starts. Default 0.
data-intervalTime between marks. Default 260 ms.
data-loopRub the marks out and count again.
data-holdHow long the full count stays before looping. Default 2600 ms.
data-labelShow the count under the marks, followed by this text, e.g. days.
data-widthStroke width. Default 4.5.
data-colorMark colour. Default the text colour.
1 · Snippet
<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>
2 · Your HTML
<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>