← Charts

Donut Fill

A ring sweeps round from twelve o’clock to its percentage while the number in the middle counts up to match. Perfect for goals, scores and progress.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-donut-fill="72" (the percentage) to an empty element and give it a width.

Options

Add any of these attributes to the same element.

data-labelSmall text under the number.
data-colorRing colour. Default #a855f7.
data-trackColour of the empty part. Default #ece6f6.
data-thicknessRing thickness as a share of the size. Default 0.12.
data-durationTime to fill. Default 1600 ms.
data-loopEmpty and refill every few seconds.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Donut Fill: a ring sweeps round from twelve o'clock to its percentage
    // while the number in the middle counts up to match.
    // Usage: <div data-donut-fill="72" style="width:160px"></div>
    // Optional: data-label (small text under the number), data-color (default
    // #a855f7), data-track (ring background, default #ece6f6), data-thickness
    // (as a share of the size, default 0.12), data-duration (ms, default 1600),
    // data-loop (empty and refill every few seconds).
    var DEFAULTS = { color: '#a855f7', track: '#ece6f6', thickness: 0.12, duration: 1600, hold: 2400 };

    var css =
      '.dfx{position:relative;display:inline-grid;place-items:center;aspect-ratio:1;width:160px;font-variant-numeric:tabular-nums}' +
      '.dfx svg{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg)}' +
      '.dfx-num{font-weight:800;letter-spacing:-0.03em;line-height:1}' +
      '.dfx-mid{display:flex;flex-direction:column;align-items:center;gap:0.25em}' +
      '.dfx-label{font-weight:500;opacity:.65;letter-spacing:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function build(el) {
      var d = el.dataset;
      var value = Math.max(0, Math.min(100, parseFloat(el.getAttribute('data-donut-fill')) || 0));
      var thick = +d.thickness || DEFAULTS.thickness;
      var sw = thick * 100, r = 50 - sw / 2;
      el.classList.add('dfx');
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', value + '%' + (d.label ? ' ' + d.label : ''));
      el.innerHTML =
        '<svg viewBox="0 0 100 100" aria-hidden="true">' +
        '<circle cx="50" cy="50" r="' + r + '" fill="none" stroke="' + (d.track || DEFAULTS.track) + '" stroke-width="' + sw + '"/>' +
        '<circle class="dfx-arc" cx="50" cy="50" r="' + r + '" fill="none" stroke="' + (d.color || DEFAULTS.color) + '" stroke-width="' + sw +
        '" stroke-linecap="round" pathLength="100" stroke-dasharray="100 100"/>' +
        '</svg><div class="dfx-mid" aria-hidden="true"><span class="dfx-num"></span>' +
        (d.label ? '<span class="dfx-label"></span>' : '') + '</div>';
      if (d.label) el.querySelector('.dfx-label').textContent = d.label;
      var c = { el: el, value: value, arc: el.querySelector('.dfx-arc'), num: el.querySelector('.dfx-num'), state: { v: 0 } };
      // Size the number from the donut, so it scales with any width.
      var label = el.querySelector('.dfx-label');
      var fit = function () {
        c.num.style.fontSize = el.clientWidth * 0.22 + 'px';
        if (label) label.style.fontSize = Math.max(11, el.clientWidth * 0.085) + 'px';
      };
      fit();
      if (window.ResizeObserver) new ResizeObserver(fit).observe(el);
      return c;
    }

    function render(c) {
      var v = c.state.v;
      // A round cap pokes out past the end, so hide the arc completely at 0.
      c.arc.style.strokeDashoffset = 100 - v;
      c.arc.style.opacity = v > 0.3 ? 1 : 0;
      c.num.textContent = Math.round(v) + '%';
    }

    function fill(c) {
      var d = c.el.dataset;
      var duration = +d.duration || DEFAULTS.duration;
      anime.animate(c.state, {
        v: [0, c.value], duration: duration, ease: 'out(3)', onUpdate: function () { render(c); },
        onComplete: function () {
          if (d.loop === undefined || d.loop === 'false') return;
          setTimeout(function () {
            anime.animate(c.state, { v: 0, duration: 600, ease: 'inOut(2)', onUpdate: function () { render(c); }, onComplete: function () { setTimeout(function () { fill(c); }, 300); } });
          }, DEFAULTS.hold);
        }
      });
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          fill(e.target._dfx);
        });
      }, { threshold: 0.4 });
      document.querySelectorAll('[data-donut-fill]').forEach(function (el) {
        if (el._dfx) return;
        var c = (el._dfx = build(el));
        if (reduced) { c.state.v = c.value; render(c); return; }
        render(c);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-donut-fill="72" data-label="complete" style="width: 160px"></div>