← Charts

Bullet Graph

A compact gauge for a figure against its goal. Shaded bands slide in to mark the poor, fair and good ranges, a target marker drops into place, and a bar fills towards it as the value counts up. The marker gives a little pulse when the bar reaches it.

Revenue
Profit
Satisfaction
New users

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-bullet-graph to a container, with one child per graph. Its text is the name.
  3. Give each graph data-value, data-target and data-ranges: the tops of the shaded bands, lowest first. The last one is the end of the scale.

Options

Add any of these attributes to the same element.

data-prefix / data-suffixText around the value, on a graph or the whole set, e.g. $ and k.
data-noteOn a graph: small text under its name.
data-colorBar colour, on a graph or the whole set. Default #6a0dad.
data-durationTime for each bar to fill. Default 1300 ms.
data-staggerGap between graphs. Default 160 ms.
data-loopEmpty the bars and fill again.

Set the bar height with the --bgh-h CSS variable (default 24px).

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Bullet Graph: a compact gauge for a figure against its goal. Shaded
    // bands mark the poor, fair and good ranges, a target marker drops into
    // place, and a bar fills towards it while the value counts up. The
    // marker gives a little pulse when the bar reaches it.
    // Usage:
    //   <div data-bullet-graph>
    //     <div data-value="270" data-target="250" data-ranges="150,225,300">Revenue</div>
    //     <div data-value="64" data-target="80" data-ranges="50,75,100" data-suffix="%">Retention</div>
    //   </div>
    // Each child is one graph: its text is the name. data-ranges are the tops
    // of the shaded bands, lowest first; the last one is the end of the scale.
    // Optional, on a graph or on the whole set: data-prefix / data-suffix
    // (around the value), data-color (bar colour, default #6a0dad). On a
    // graph: data-note (small text under the name). On the set: data-duration (ms for each
    // bar to fill, default 1300), data-stagger (gap between graphs, default
    // 160), data-loop (empty the bars and fill again).
    var DEFAULTS = { duration: 1300, stagger: 160, hold: 2600, color: '#6a0dad' };

    var css =
      '.bgh{display:grid;grid-template-columns:minmax(0,auto) minmax(0,1fr) auto;gap:14px 12px;align-items:center;font-variant-numeric:tabular-nums}' +
      '.bgh-name{font-size:13px;font-weight:700;text-align:right;line-height:1.15;min-width:0;max-width:9em;overflow-wrap:anywhere}' +
      '.bgh-name small{display:block;font-size:11px;font-weight:500;opacity:.6}' +
      '.bgh-track{position:relative;height:var(--bgh-h,24px);border-radius:4px;overflow:hidden}' +
      '.bgh-band{position:absolute;left:0;top:0;bottom:0;background:currentColor;transform-origin:0 50%}' +
      '.bgh-bar{position:absolute;left:0;top:33%;height:34%;width:0;border-radius:0 3px 3px 0;background:var(--bgh-c)}' +
      '.bgh-mark{position:absolute;top:12%;bottom:12%;width:3px;margin-left:-1.5px;border-radius:2px;background:currentColor}' +
      '.bgh-val{font-size:13px;font-weight:700;min-width:3.2em;text-align:left;white-space:nowrap}';
    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 attr(row, el, name) { var v = row.getAttribute('data-' + name); return v !== null ? v : el.getAttribute('data-' + name); }

    function setup(el) {
      var rows = [];
      Array.prototype.forEach.call(el.children, function (r) {
        var v = parseFloat(r.getAttribute('data-value'));
        if (isNaN(v)) return;
        var t = parseFloat(r.getAttribute('data-target'));
        var ranges = (r.getAttribute('data-ranges') || '').split(/[\s,]+/).map(parseFloat).filter(function (x) { return !isNaN(x); });
        var max = Math.max.apply(null, ranges.concat([v, isNaN(t) ? 0 : t])) || 1;
        if (!ranges.length || ranges[ranges.length - 1] < max) ranges.push(max * (ranges.length ? 1 : 1.15));
        max = ranges[ranges.length - 1];
        var dec = (String(v).split('.')[1] || '').length;
        rows.push({
          name: r.textContent.trim(), v: v, t: t, ranges: ranges, max: max, dec: dec,
          pre: attr(r, el, 'prefix') || '', suf: attr(r, el, 'suffix') || '',
          note: r.getAttribute('data-note'), color: attr(r, el, 'color') || DEFAULTS.color
        });
      });
      el.classList.add('bgh');
      el.setAttribute('role', 'img');
      function fmt(r, x) { return r.pre + x.toFixed(r.dec) + r.suf; }
      el.setAttribute('aria-label', rows.map(function (r) {
        return r.name + ': ' + fmt(r, r.v) + (isNaN(r.t) ? '' : ' against a target of ' + fmt(r, r.t));
      }).join('. ') + '.');
      var html = '';
      rows.forEach(function (r) {
        var n = r.ranges.length, bands = '';
        // Widest band first, so each narrower one sits on top a shade darker.
        for (var i = n - 1; i >= 0; i--) {
          bands += '<span class="bgh-band" style="width:' + (r.ranges[i] / r.max * 100) + '%;opacity:' + (0.08 + (n - 1 - i) * (0.16 / Math.max(1, n - 1))).toFixed(3) + '"></span>';
        }
        html += '<div class="bgh-name" aria-hidden="true">' + esc(r.name) + (r.note ? '<small>' + esc(r.note) + '</small>' : '') + '</div>' +
          '<div class="bgh-track" aria-hidden="true" style="--bgh-c:' + esc(r.color) + '">' + bands + '<span class="bgh-bar"></span>' +
          (isNaN(r.t) ? '' : '<span class="bgh-mark" style="left:' + (r.t / r.max * 100) + '%"></span>') + '</div>' +
          '<div class="bgh-val" aria-hidden="true"></div>';
      });
      el.innerHTML = html;
      var tracks = el.querySelectorAll('.bgh-track'), vals = el.querySelectorAll('.bgh-val');
      rows.forEach(function (r, i) {
        r.track = tracks[i];
        r.bands = Array.prototype.slice.call(tracks[i].querySelectorAll('.bgh-band'));
        r.bar = tracks[i].querySelector('.bgh-bar');
        r.mark = tracks[i].querySelector('.bgh-mark');
        r.val = vals[i];
        r.fmt = function (x) { return fmt(r, x); };
      });
      var c = { el: el, rows: rows };
      el._bgh = c;
      return c;
    }

    function set(r, k) {
      var x = r.v * k;
      r.bar.style.width = (x / r.max * 100) + '%';
      r.val.textContent = r.fmt(x);
    }

    function finish(c) {
      c.rows.forEach(function (r) { set(r, 1); });
    }

    function play(c) {
      var d = c.el.dataset;
      var dur = +d.duration || DEFAULTS.duration, stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      var tl = anime.createTimeline();
      c.rows.forEach(function (r, i) {
        var at = i * stagger, st = { k: 0 }, hit = false;
        set(r, 0);
        tl.add(r.bands, { scaleX: [0, 1], duration: 500, delay: anime.stagger(70), ease: 'out(3)' }, at);
        if (r.mark) tl.add(r.mark, { opacity: [0, 1], translateY: [-14, 0], duration: 500, ease: 'outBack(2)' }, at + 300);
        tl.add(st, {
          k: 1, duration: dur, ease: 'out(3)',
          onUpdate: function () {
            set(r, st.k);
            if (r.mark && !hit && r.v * st.k >= r.t) {
              hit = true;
              anime.animate(r.mark, { scaleY: [1, 1.35, 1], scaleX: [1, 1.6, 1], duration: 450, ease: 'out(2)' });
            }
          }
        }, at + 500);
      });
      if (d.loop !== undefined && d.loop !== 'false') {
        tl.then(function () {
          setTimeout(function () {
            var o = { k: 1 };
            anime.animate(o, {
              k: 0, duration: 500, ease: 'in(2)',
              onUpdate: function () { c.rows.forEach(function (r) { set(r, o.k); }); },
              onComplete: function () { setTimeout(function () { play(c); }, 300); }
            });
          }, 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._bgh);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-bullet-graph]').forEach(function (el) {
        if (el._bgh) return;
        var c = setup(el);
        if (reduced()) { finish(c); return; }
        c.rows.forEach(function (r) {
          set(r, 0);
          anime.utils.set(r.bands, { scaleX: 0 });
          if (r.mark) anime.utils.set(r.mark, { opacity: 0 });
        });
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-bullet-graph>
  <div data-value="270" data-target="250" data-ranges="150,225,300"
       data-prefix="$" data-suffix="k">Revenue</div>
  <div data-value="22" data-target="26" data-ranges="12,20,30"
       data-suffix="%">Profit</div>
</div>