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
- Paste the snippet just before
</head>. - Add
data-bullet-graphto a container, with one child per graph. Its text is the name. - Give each graph
data-value,data-targetanddata-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-suffix | Text around the value, on a graph or the whole set, e.g. $ and k. |
data-note | On a graph: small text under its name. |
data-color | Bar colour, on a graph or the whole set. Default #6a0dad. |
data-duration | Time for each bar to fill. Default 1300 ms. |
data-stagger | Gap between graphs. Default 160 ms. |
data-loop | Empty the bars and fill again. |
Set the bar height with the --bgh-h CSS variable (default 24px).
<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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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><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>