← Charts

Waffle Chart

A ten-by-ten grid where each square is one per cent. The squares pop into colour one after another while the percentage counts up beside it. Give it several values and each fills in its own colour in turn.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-waffle-chart="62" to an empty element, or several values such as data-waffle-chart="46,27,12". Values are percentages, rounded to whole squares.

Options

Add any of these attributes to the same element.

data-labelText under a single percentage.
data-labelsComma-separated names for several values, shown as a legend.
data-colorsComma-separated colours, one per value. Default purple, pink, blue…
data-orderrows (from the bottom left, the default), columns or random.
data-sidefalse shows just the grid.
data-durationTime to fill every square. Default 1800 ms.
data-loopEmpty and fill again every few seconds.

Size the grid with the --wff-size CSS variable (default 200px) and the big number with --wff-big. The legend wraps under the grid on a narrow screen.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Waffle Chart: a ten-by-ten grid where each square is one per cent. The
    // squares fill in one by one, popping into colour, while the percentage
    // counts up beside the grid. Give it several values and each fills in its
    // own colour, one after another.
    // Usage: <div data-waffle-chart="62" data-label="came back within a week"></div>
    //    or: <div data-waffle-chart="45,25,12" data-labels="Mobile,Desktop,Tablet"></div>
    // Values are percentages (they're rounded to whole squares).
    // Optional: data-label (text under a single percentage), data-labels
    // (comma-separated names for several values, shown as a legend),
    // data-colors (comma-separated, one per value; default purple, pink,
    // blue…), data-order ("rows", the default, fills row by row from the
    // bottom left; "columns" column by column; "random"), data-side="false"
    // (just the grid), data-duration (ms to fill, default 1800), data-loop
    // (empty and fill again every few seconds).
    var DEFAULTS = { duration: 1800, hold: 2600 };
    var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1'];

    var css =
      '.wff{display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:14px 22px;font-variant-numeric:tabular-nums}' +
      '.wff-grid{display:grid;grid-template-columns:repeat(10,1fr);gap:var(--wff-gap,3px);width:var(--wff-size,200px);max-width:100%;aspect-ratio:1;flex:none}' +
      '.wff-sq{position:relative;border-radius:22%;background:rgba(127,127,127,.16);overflow:hidden}' +
      '.wff-sq i{position:absolute;inset:0;border-radius:inherit;background:var(--wff-c);transform:scale(0)}' +
      '.wff-side{display:flex;flex-direction:column;gap:6px;min-width:0;max-width:100%}' +
      '.wff-big{font-size:var(--wff-big,40px);font-weight:800;line-height:1;color:var(--wff-c)}' +
      '.wff-cap{font-size:13px;opacity:.7;line-height:1.3;max-width:16em}' +
      '.wff-key{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;white-space:nowrap}' +
      '.wff-key i{width:11px;height:11px;border-radius:3px;background:var(--wff-c);flex:none}' +
      '.wff-key b{margin-left:auto;padding-left:10px;color:var(--wff-c)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
    function pct(v) { return (v % 1 ? v.toFixed(1) : v) + '%'; }

    // Square order: index into a row-major grid, top left = 0.
    function order(kind) {
      var list = [], r, col;
      if (kind === 'columns') { for (col = 0; col < 10; col++) for (r = 9; r >= 0; r--) list.push(r * 10 + col); }
      else { for (r = 9; r >= 0; r--) for (col = 0; col < 10; col++) list.push(r * 10 + col); }
      if (kind === 'random') {
        for (var i = list.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)), t = list[i]; list[i] = list[j]; list[j] = t; }
      }
      return list;
    }

    function setup(el) {
      var d = el.dataset;
      var values = (el.getAttribute('data-waffle-chart') || '0').split(',').map(function (v) { return Math.max(0, +v || 0); });
      var labels = d.labels ? d.labels.split(',') : [];
      var colors = d.colors ? d.colors.split(',') : PALETTE;
      var single = values.length === 1;

      // Which value each square shows, in fill order.
      var seq = order(d.order), owner = [], at = 0;
      values.forEach(function (v, s) {
        var n = Math.round(v);
        for (var k = 0; k < n && at < 100; k++) owner[seq[at++]] = s;
      });

      el.classList.add('wff');
      el.style.setProperty('--wff-c', colors[0]);
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Waffle chart: ' + values.map(function (v, s) {
        return pct(v) + (labels[s] ? ' ' + labels[s] : single && d.label ? ' ' + d.label : '');
      }).join(', '));
      var html = '<div class="wff-grid" aria-hidden="true">';
      for (var i = 0; i < 100; i++) {
        html += '<span class="wff-sq">' + (owner[i] !== undefined ? '<i style="--wff-c:' + esc(colors[owner[i] % colors.length]) + '"></i>' : '') + '</span>';
      }
      html += '</div>';
      if (d.side !== 'false') {
        html += '<div class="wff-side" aria-hidden="true">' + (single
          ? '<span class="wff-big">0%</span>' + (d.label ? '<span class="wff-cap">' + esc(d.label) + '</span>' : '')
          : values.map(function (v, s) {
            return '<span class="wff-key" style="--wff-c:' + esc(colors[s % colors.length]) + '"><i></i>' + esc(labels[s] || 'Series ' + (s + 1)) + '<b>0%</b></span>';
          }).join('')) + '</div>';
      }
      el.innerHTML = html;

      var sq = el.querySelectorAll('.wff-sq');
      var c = { el: el, values: values };
      c.fills = seq.slice(0, at).map(function (k) { return sq[k].firstChild; });
      c.counters = Array.prototype.slice.call(el.querySelectorAll(single ? '.wff-big' : '.wff-key b'));
      // Where each value's squares start and end in the fill order.
      c.spans = []; var a = 0;
      values.forEach(function (v) { var n = Math.min(100 - a, Math.round(v)); c.spans.push([a, a + n]); a += n; });
      el._wff = c;
      return c;
    }

    function finish(c) {
      anime.utils.set(c.fills, { scale: 1 });
      c.counters.forEach(function (t, s) { t.textContent = pct(c.values[s]); });
    }

    function play(c) {
      var d = c.el.dataset;
      var duration = +d.duration || DEFAULTS.duration;
      var n = c.fills.length || 1, step = duration / n;
      anime.utils.set(c.fills, { scale: 0 });
      anime.animate(c.fills, {
        scale: [{ to: 1.25, duration: 160, ease: 'out(2)' }, { to: 1, duration: 220, ease: 'inOut(2)' }],
        delay: anime.stagger(step)
      });
      c.counters.forEach(function (t, s) {
        var span = c.spans[s], o = { v: 0 };
        t.textContent = '0%';
        anime.animate(o, {
          v: c.values[s], duration: Math.max(1, (span[1] - span[0]) * step), delay: span[0] * step + 160, ease: 'linear',
          onUpdate: function () { t.textContent = Math.round(o.v) + '%'; },
          onComplete: function () { t.textContent = pct(c.values[s]); }
        });
      });
      if (d.loop !== undefined && d.loop !== 'false') {
        setTimeout(function () {
          anime.animate(c.fills.slice().reverse(), {
            scale: 0, duration: 260, ease: 'in(2)', delay: anime.stagger(Math.min(6, 500 / n)),
            onComplete: function () { setTimeout(function () { play(c); }, 300); }
          });
          c.counters.forEach(function (t, s) {
            var o = { v: c.values[s] };
            anime.animate(o, { v: 0, duration: 600, ease: 'in(2)', onUpdate: function () { t.textContent = Math.round(o.v) + '%'; } });
          });
        }, duration + DEFAULTS.hold);
      }
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target._wff);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-waffle-chart]').forEach(function (el) {
        if (el._wff) return;
        var c = setup(el);
        if (reduced()) { finish(c); return; }
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-waffle-chart="62" data-label="came back within a week"></div>

<!-- or, several values: -->
<div data-waffle-chart="46,27,12" data-labels="Mobile,Desktop,Tablet"></div>