← Charts

Icon Array

A row of little figures that fill with colour one after another, from the feet up, to show a proportion such as 7 in 10 people. The count underneath ticks up as each one fills.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-icon-array="7" to an empty element. It fills 7 of 10 icons; the value can have a fraction, so 7.5 fills half of the eighth.

Options

Add any of these attributes to the same element.

data-totalHow many icons. Default 10.
data-labelText after “7 in 10”. Default people; leave it empty for none.
data-iconperson (the default), heart, star or dot.
data-columnsIcons per row. Default up to 10.
data-colorFill colour. Default #a855f7.
data-captionfalse hides the count.
data-durationTime to fill every icon. Default 1800 ms.
data-loopEmpty and fill again every few seconds.

Size the icons with the --iar-size CSS variable (default 36px); they shrink to fit a narrow screen.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Icon Array: a row (or grid) of little figures that fill with colour one
    // after another, rising from the feet up, to show a proportion such as
    // "7 in 10 people". The count under them ticks up as each one fills.
    // Usage: <div data-icon-array="7" data-total="10" data-label="people"></div>
    // The value can have a fraction (7.5 fills half of the eighth icon).
    // Optional: data-total (how many icons, default 10), data-label (text
    // after "7 in 10", default "people"; empty for none), data-icon ("person",
    // the default, "heart", "star" or "dot"), data-columns (icons per row,
    // default up to 10), data-color (default #a855f7), data-caption="false"
    // (no count), data-duration (ms to fill every icon, default 1800),
    // data-loop (empty and fill again every few seconds).
    var DEFAULTS = { total: 10, label: 'people', color: '#a855f7', duration: 1800, hold: 2600 };
    var ICONS = {
      person: '<circle cx="12" cy="6.2" r="4.2"/><path d="M12 12.4c4.6 0 8 2.9 8 6.4v1.7c0 1-.8 1.5-1.7 1.5H5.7c-.9 0-1.7-.5-1.7-1.5v-1.7c0-3.5 3.4-6.4 8-6.4z"/>',
      heart: '<path d="M12 21.2s-7.6-4.7-9.7-9.4C.8 8.4 2.9 4.4 6.7 4.4c2.2 0 3.7 1.2 5.3 3.1 1.6-1.9 3.1-3.1 5.3-3.1 3.8 0 5.9 4 4.4 7.4C19.6 16.5 12 21.2 12 21.2z"/>',
      star: '<path d="M12 2.4l2.9 6.2 6.7.8-5 4.6 1.4 6.7L12 17.4l-6 3.3 1.4-6.7-5-4.6 6.7-.8z"/>',
      dot: '<circle cx="12" cy="12" r="9.5"/>'
    };

    var css =
      '.iar{display:flex;flex-direction:column;align-items:center;gap:12px;font-variant-numeric:tabular-nums}' +
      '.iar-grid{display:grid;grid-template-columns:repeat(var(--iar-cols),minmax(0,var(--iar-size,36px)));gap:var(--iar-gap,6px);justify-content:center;width:100%}' +
      '.iar-icon{position:relative;display:block;aspect-ratio:1}' +
      '.iar-icon svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.iar-base{fill:currentColor;opacity:.16}' +
      '.iar-fill{fill:var(--iar-c)}' +
      '.iar-full svg{animation:iar-pop .4s ease-out}' +
      '@keyframes iar-pop{40%{transform:scale(1.18)}}' +
      '.iar-cap{font-size:var(--iar-text,15px);font-weight:600;line-height:1.2;text-align:center}' +
      '.iar-cap b{font-size:1.7em;font-weight:800;color:var(--iar-c);vertical-align:-.08em}';
    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 setup(el) {
      var d = el.dataset;
      var total = Math.max(1, Math.round(+d.total || DEFAULTS.total));
      var value = Math.max(0, Math.min(total, +el.getAttribute('data-icon-array') || 0));
      var label = d.label !== undefined ? d.label : DEFAULTS.label;
      var shape = ICONS[d.icon] || ICONS.person;
      var cols = Math.max(1, +d.columns || Math.min(total, 10));
      var color = d.color || DEFAULTS.color;
      var text = function (v) { return (v % 1 ? v.toFixed(1) : v) + ''; };

      el.classList.add('iar');
      el.style.setProperty('--iar-c', color);
      el.style.setProperty('--iar-cols', cols);
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', text(value) + ' in ' + total + (label ? ' ' + label : ''));
      var html = '<div class="iar-grid" aria-hidden="true">';
      for (var i = 0; i < total; i++) {
        html += '<span class="iar-icon"><svg viewBox="0 0 24 24"><g class="iar-base">' + shape + '</g>' +
          '<g class="iar-fill" style="clip-path:inset(100% 0 0 0)">' + shape + '</g></svg></span>';
      }
      html += '</div>';
      if (d.caption !== 'false') {
        html += '<p class="iar-cap" aria-hidden="true" style="margin:0"><b>0</b> in ' + total + (label ? ' ' + esc(label) : '') + '</p>';
      }
      el.innerHTML = html;

      var c = { el: el, value: value, text: text };
      c.icons = Array.prototype.slice.call(el.querySelectorAll('.iar-icon'));
      c.fills = c.icons.map(function (ic) { return ic.querySelector('.iar-fill'); });
      c.num = el.querySelector('.iar-cap b');
      el._iar = c;
      return c;
    }

    // Fill to p icons (0 to value): each fills from the bottom up, then the next starts.
    function render(c, p) {
      c.fills.forEach(function (f, i) {
        var k = Math.max(0, Math.min(1, p - i));
        f.style.clipPath = 'inset(' + ((1 - k) * 100).toFixed(1) + '% 0 0 0)';
        var full = k >= 1;
        if (full !== c.icons[i].classList.contains('iar-full')) c.icons[i].classList.toggle('iar-full', full);
      });
      if (c.num) c.num.textContent = p >= c.value ? c.text(c.value) : Math.floor(p);
    }

    function play(c) {
      var d = c.el.dataset;
      var duration = +d.duration || DEFAULTS.duration;
      var st = { p: 0 };
      render(c, 0);
      anime.animate(st, {
        p: c.value, duration: duration * Math.max(0.3, c.value / (c.fills.length || 1)), ease: 'inOut(1.4)',
        onUpdate: function () { render(c, st.p); },
        onComplete: function () {
          render(c, c.value);
          if (d.loop === undefined || d.loop === 'false') return;
          setTimeout(function () {
            var o = { p: c.value };
            anime.animate(o, {
              p: 0, duration: 600, ease: 'in(2)',
              onUpdate: function () { render(c, o.p); },
              onComplete: function () { setTimeout(function () { play(c); }, 350); }
            });
          }, 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._iar);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-icon-array]').forEach(function (el) {
        if (el._iar) return;
        var c = setup(el);
        if (reduced()) { render(c, c.value); c.icons.forEach(function (i) { i.classList.remove('iar-full'); }); return; }
        render(c, 0);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-icon-array="7" data-label="people"></div>

<!-- or, 25 in 100 as hearts: -->
<div data-icon-array="25" data-total="100" data-icon="heart"></div>