← Charts

Lollipop Chart

A lighter take on a bar chart. Thin stems grow up from the axis one after another, and as each reaches its value a dot pops on top with the figure appearing above it.

JanFebMarAprMayJunJulAugSepOctNovDec

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-lollipop-chart to a container with a height, with one child per lollipop: its text is the label and data-value the figure.

Options

Add any of these attributes to the same element.

data-colorColour, on the chart or on one lollipop to pick it out. Default #a855f7.
data-maxTop of the scale. Default a round number above the largest value.
data-prefix / data-suffixText around the values, e.g. mm.
data-valuesfalse hides the figures.
data-durationTime for each stem to grow. Default 700 ms.
data-staggerGap between stems. Default 90 ms.
data-loopDrop and grow again every few seconds.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Lollipop Chart: a lighter take on a bar chart. Thin stems grow up from
    // the axis one after another, and as each one reaches its value a dot
    // pops on top with the figure appearing above it.
    // Usage:
    //   <div data-lollipop-chart style="height:240px">
    //     <span data-value="42">Mon</span>
    //     <span data-value="68">Tue</span>
    //   </div>
    // Each child is one lollipop: its text is the label.
    // Optional: data-color (default #a855f7; also on one lollipop to pick it
    // out), data-max (top of the scale, default a round number above the
    // largest value), data-prefix / data-suffix (around the values),
    // data-values="false" (no figures), data-duration (ms for each stem to
    // grow, default 700), data-stagger (gap between stems, default 90),
    // data-loop (drop and grow again every few seconds).
    var DEFAULTS = { duration: 700, stagger: 90, hold: 2600, color: '#a855f7' };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.llp{position:relative;font-size:11px;font-variant-numeric:tabular-nums}' +
      '.llp svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.llp-grid{stroke:currentColor;stroke-opacity:.08}' +
      '.llp-axis{stroke:currentColor;stroke-opacity:.3}' +
      '.llp-stem{stroke-width:2.5;stroke-linecap:round}' +
      '.llp-dot{stroke:#fff;stroke-width:2}' +
      '.llp-val{fill:currentColor;font-weight:700;text-anchor:middle}' +
      '.llp-lab{fill:currentColor;fill-opacity:.6;text-anchor:middle}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function mk(tag, attrs, parent) {
      var n = document.createElementNS(NS, tag);
      for (var k in attrs) n.setAttribute(k, attrs[k]);
      if (parent) parent.appendChild(n);
      return n;
    }
    function clamp01(x) { return x < 0 ? 0 : x > 1 ? 1 : x; }
    function back(x) { x = clamp01(x); var s = 2.6; x -= 1; return x * x * ((s + 1) * x + s) + 1; }

    function setup(el) {
      var d = el.dataset;
      var items = [];
      Array.prototype.forEach.call(el.children, function (b) {
        var v = parseFloat(b.getAttribute('data-value'));
        if (isNaN(v) || v < 0) return;
        items.push({ label: b.textContent.trim(), v: v, color: b.getAttribute('data-color') || d.color || DEFAULTS.color });
      });
      var hi = Math.max.apply(null, items.map(function (it) { return it.v; })) || 1;
      var raw = hi / 4, p = Math.pow(10, Math.floor(Math.log10(raw)));
      var step = [1, 2, 2.5, 5, 10].map(function (m) { return m * p; }).filter(function (s) { return s >= raw; })[0];
      var max = +d.max || Math.ceil(hi * 1.08 / step) * step;
      var dec = items.some(function (it) { return it.v % 1; }) ? 1 : 0;
      var pre = d.prefix || '', suf = d.suffix || '';
      var c = { el: el, items: items, max: max, step: step, k: items.map(function () { return 0; }) };
      c.fmt = function (v) { return pre + v.toFixed(dec) + suf; };
      c.bare = function (v) { return v.toFixed(dec); };
      el.classList.add('llp');
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Lollipop chart. ' + items.map(function (it) { return it.label + ': ' + c.fmt(it.v); }).join(', ') + '.');
      el._llp = c;
      layout(c);
      return c;
    }

    function layout(c) {
      var W = c.el.clientWidth, H = c.el.clientHeight, d = c.el.dataset;
      if (!W || !H) return;
      c.W = W; c.H = H;
      var top = d.values === 'false' ? 10 : 24, base = H - 20;
      c.base = base;
      c.y = function (v) { return base - v / c.max * (base - top); };
      var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
      for (var t = c.step; t <= c.max + 1e-9; t += c.step) mk('line', { class: 'llp-grid', x1: 0, x2: W, y1: c.y(t), y2: c.y(t) }, svg);
      mk('line', { class: 'llp-axis', x1: 0, x2: W, y1: base + .5, y2: base + .5 }, svg);
      var slot = W / c.items.length;
      var r = Math.max(4, Math.min(8, slot * 0.2));
      // Thin out the labels if they'd crowd each other.
      var longest = Math.max.apply(null, c.items.map(function (it) { return it.label.length; }));
      var every = Math.max(1, Math.ceil(longest * 6.2 / (slot - 4)));
      // Figures too wide for their slot drop their prefix and suffix.
      var widest = Math.max.apply(null, c.items.map(function (it) { return c.fmt(it.v).length; }));
      c.show = widest * 6.6 > slot - 2 ? c.bare : c.fmt;
      c.items.forEach(function (it, i) {
        var x = slot * (i + 0.5);
        it.stem = mk('line', { class: 'llp-stem', x1: x, x2: x, y1: base, y2: base, stroke: it.color }, svg);
        it.dot = mk('circle', { class: 'llp-dot', cx: x, cy: c.y(it.v), r: 0, fill: it.color }, svg);
        it.r = r;
        if (d.values !== 'false') it.val = mk('text', { class: 'llp-val', x: x, y: c.y(it.v) - r - 5 }, svg);
        if (i % every === 0) mk('text', { class: 'llp-lab', x: x, y: H - 4 }, svg).textContent = it.label;
      });
      c.el.innerHTML = '';
      c.el.appendChild(svg);
      render(c);
    }

    // Lollipop i at k (0–1): the stem grows over the first 70%, then the dot pops.
    function render(c) {
      if (!c.items[0] || !c.items[0].stem) return;
      c.items.forEach(function (it, i) {
        var k = c.k[i], g = clamp01(k / 0.7), e = 1 - Math.pow(1 - g, 4);
        it.stem.setAttribute('y2', c.base + (c.y(it.v) - c.base) * e);
        it.stem.style.opacity = k > 0 ? 1 : 0;
        var pop = back((k - 0.62) / 0.38);
        it.dot.setAttribute('r', Math.max(0, it.r * pop));
        if (it.val) {
          it.val.style.opacity = clamp01((k - 0.7) / 0.2);
          it.val.textContent = c.show(it.v * e);
        }
      });
    }

    function play(c) {
      var d = c.el.dataset;
      var dur = +d.duration || DEFAULTS.duration, stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      c.k = c.k.map(function () { return 0; });
      render(c);
      var tl = anime.createTimeline({ onUpdate: function () { render(c); } });
      c.items.forEach(function (it, i) {
        var s = { k: 0 };
        tl.add(s, { k: 1, duration: dur / 0.7, ease: 'linear', onUpdate: function () { c.k[i] = s.k; } }, i * stagger);
      });
      if (d.loop !== undefined && d.loop !== 'false') {
        tl.then(function () {
          setTimeout(function () {
            var o = { k: 1 };
            anime.animate(o, {
              k: 0, duration: 550, ease: 'in(2)',
              onUpdate: function () { c.k = c.k.map(function () { return o.k; }); render(c); },
              onComplete: function () { setTimeout(function () { play(c); }, 250); }
            });
          }, 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._llp);
        });
      }, { threshold: 0.3 });
      var ro = new ResizeObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._llp;
          if (c && (e.target.clientWidth !== c.W || e.target.clientHeight !== c.H)) layout(c);
        });
      });
      document.querySelectorAll('[data-lollipop-chart]').forEach(function (el) {
        if (el._llp) return;
        var c = setup(el);
        ro.observe(el);
        if (reduced()) { c.k = c.k.map(function () { return 1; }); render(c); return; }
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-lollipop-chart style="height: 240px">
  <span data-value="42">Mon</span>
  <span data-value="68">Tue</span>
  <span data-value="55" data-color="#ec4899">Wed</span>
</div>