← 3D & depth

Isometric Bar Chart

A bar chart in isometric 3D. Bars rise out of a tiled floor one after another, sweeping from back to front in a wave and settling with a little bounce. Give it several rows of values and you get a whole grid of bars, one colour per row.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-isometric-bar-chart to a box with a width, and list your numbers in data-values, separated by commas.
  3. For more than one row of bars, separate the rows with semicolons.

Options

Add any of these attributes to the same element.

data-valuesThe numbers, e.g. 3,6,4;5,2,7 for two rows of three. Required.
data-labelsNames for the columns, separated by commas, written along the front of the floor.
data-show-valuesPrint each number on top of its bar.
data-colorsOne colour per row, separated by commas. Default violet, pink and sky blue.
data-maxThe value of the tallest possible bar. Default the largest value.
data-heightHeight of the tallest bar, in floor tiles. Default 3.
data-loopSink the bars and raise them again.
data-holdHow long the bars stand before sinking, when looping. Default 2600 ms.
data-staggerGap between neighbouring bars. Default 90 ms.

Drawn as SVG, so it’s sharp at any size; the chart’s numbers are read out as its label. It starts when scrolled into view; with reduced motion the bars are simply there.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Isometric Bar Chart: a bar chart drawn in isometric 3D. The bars rise
    // out of a tiled floor one after another, front to back in a wave, and
    // settle with a little bounce. Several rows of values make a grid of bars.
    // Usage: <div data-isometric-bar-chart data-values="3,6,4,8,5"></div>
    // Rows are separated by semicolons: data-values="3,6,4;5,2,7".
    // Optional: data-labels (comma list naming the columns), data-colors
    // (comma list, one per row, default violet, pink, sky), data-show-values
    // (print each value on top of its bar), data-max (value of the tallest
    // possible bar, default the largest value), data-height (tallest bar, in
    // floor tiles, default 3), data-loop (sink and rise again), data-hold (ms
    // before sinking, default 2600), data-stagger (ms between bars, default 90).
    var DEFAULTS = { colors: '#8b5cf6,#ec4899,#38bdf8', height: 3, hold: 2600, stagger: 90 };
    var NS = 'http://www.w3.org/2000/svg';
    var U = 20; // one floor tile, in SVG units

    var css =
      '.ibc-svg{display:block;width:100%;height:100%;overflow:visible}' +
      '.ibc-label{font:600 9px system-ui,sans-serif;fill:#6b5b8a;text-anchor:middle}' +
      '.ibc-val{font:700 10px system-ui,sans-serif;fill:#3b0764;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 P(x, y, z) { return [(x - y) * 0.866 * U, ((x + y) * 0.5 - z) * U]; }
    function pts(list) { return list.map(function (p) { return p[0].toFixed(1) + ',' + p[1].toFixed(1); }).join(' '); }
    function shade(hex, f) {
      hex = hex.replace('#', '');
      if (hex.length === 3) hex = hex.replace(/./g, '$&$&');
      var n = parseInt(hex, 16);
      return 'rgb(' + [n >> 16 & 255, n >> 8 & 255, n & 255].map(function (v) {
        return Math.round(f > 1 ? v + (255 - v) * (f - 1) : v * f);
      }).join(',') + ')';
    }
    function make(tag, attrs, parent) {
      var e = document.createElementNS(NS, tag);
      for (var k in attrs) e.setAttribute(k, attrs[k]);
      if (parent) parent.appendChild(e);
      return e;
    }
    // The three faces you can see of a box from x, y to x + w, y + w, height h.
    function faces(x, y, w, d, h) {
      return [
        [P(x, y, h), P(x + w, y, h), P(x + w, y + d, h), P(x, y + d, h)],
        [P(x, y + d, 0), P(x + w, y + d, 0), P(x + w, y + d, h), P(x, y + d, h)],
        [P(x + w, y, 0), P(x + w, y + d, 0), P(x + w, y + d, h), P(x + w, y, h)]
      ];
    }

    function setup(el) {
      var d = el.dataset;
      var rows = (d.values || '').split(';').map(function (r) {
        return r.split(',').map(function (v) { return Math.max(0, parseFloat(v) || 0); });
      }).filter(function (r) { return r.length; });
      if (!rows.length) return;
      var cols = Math.max.apply(null, rows.map(function (r) { return r.length; }));
      var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
      var labels = d.labels ? d.labels.split(',') : null;
      var all = [].concat.apply([], rows);
      var max = +d.max || Math.max.apply(null, all) || 1;
      var tall = +d.height || DEFAULTS.height;
      var showValues = d.showValues !== undefined && d.showValues !== 'false';
      var R = rows.length;

      var svg = make('svg', { class: 'ibc-svg', role: 'img', preserveAspectRatio: 'xMidYMid meet' });
      var desc = rows.map(function (r, i) {
        return (R > 1 ? 'Row ' + (i + 1) + ': ' : '') + r.map(function (v, j) { return (labels && labels[j] ? labels[j].trim() + ' ' : '') + v; }).join(', ');
      }).join('. ');
      svg.setAttribute('aria-label', 'Bar chart. ' + desc);

      // The floor: a slab with a tile grid on top.
      var m = 0.3, t = 0.28;
      var floor = make('g', { 'aria-hidden': 'true' }, svg);
      var slab = faces(-m, -m, cols + 2 * m, R + 2 * m, 0);
      var down = function (p) { return [p[0], p[1] + t * U]; };
      make('polygon', { points: pts([slab[1][0], slab[1][1], down(slab[1][1]), down(slab[1][0])]), fill: '#c4b5fd' }, floor);
      make('polygon', { points: pts([slab[2][0], slab[2][1], down(slab[2][1]), down(slab[2][0])]), fill: '#ddd6fe' }, floor);
      make('polygon', { points: pts(slab[0]), fill: '#f5f3ff', stroke: '#ddd6fe', 'stroke-width': 0.6 }, floor);
      for (var gx = 0; gx <= cols; gx++) {
        var a = P(gx, 0, 0), b = P(gx, R, 0);
        make('line', { x1: a[0], y1: a[1], x2: b[0], y2: b[1], stroke: '#ddd6fe', 'stroke-width': 0.6 }, floor);
      }
      for (var gy = 0; gy <= R; gy++) {
        var c = P(0, gy, 0), e = P(cols, gy, 0);
        make('line', { x1: c[0], y1: c[1], x2: e[0], y2: e[1], stroke: '#ddd6fe', 'stroke-width': 0.6 }, floor);
      }
      if (labels) {
        labels.forEach(function (l, j) {
          var p = P(j + 0.5, R + m + t + 0.55, 0);
          var tx = make('text', { class: 'ibc-label', x: p[0], y: p[1] + 4, 'aria-hidden': 'true' }, svg);
          tx.textContent = l.trim();
        });
      }

      // Bars, back to front so nearer ones paint over farther ones.
      var bw = 0.6, off = (1 - bw) / 2;
      var bars = [];
      rows.forEach(function (r, i) {
        r.forEach(function (v, j) {
          bars.push({ i: i, j: j, v: v, target: v / max * tall, h: 0, order: i + j, color: colors[i % colors.length] });
        });
      });
      bars.sort(function (p, q) { return p.order - q.order || p.j - q.j; });
      bars.forEach(function (bar) {
        var g = make('g', { 'aria-hidden': 'true' }, svg);
        bar.polys = [shade(bar.color, 1.18), shade(bar.color, 0.78), bar.color].map(function (fill) {
          return make('polygon', { fill: fill }, g);
        });
        if (showValues) {
          bar.text = make('text', { class: 'ibc-val', opacity: 0 }, g);
          bar.text.textContent = bar.v;
        }
      });

      function drawBar(bar) {
        var h = Math.max(0.001, bar.h);
        var f = faces(bar.j + off, bar.i + off, bw, bw, h);
        bar.polys.forEach(function (p, k) { p.setAttribute('points', pts(f[k])); });
        if (bar.text) {
          var top = P(bar.j + 0.5, bar.i + 0.5, h);
          bar.text.setAttribute('x', top[0].toFixed(1));
          bar.text.setAttribute('y', (top[1] - 6).toFixed(1));
          bar.text.setAttribute('opacity', Math.max(0, Math.min(1, (bar.h / (bar.target || 1) - 0.7) / 0.3)).toFixed(2));
        }
      }
      function draw() { bars.forEach(drawBar); }

      // Fit the view box round everything at full height.
      var xs = [], ys = [];
      [P(-m, -m, 0), P(cols + m, -m, 0), P(-m, R + m, 0), P(cols + m, R + m, 0)].forEach(function (p) {
        xs.push(p[0]); ys.push(p[1] + t * U);
      });
      bars.forEach(function (bar) {
        var p = P(bar.j + off, bar.i + off, bar.target * 1.08);
        ys.push(p[1] - (showValues ? 18 : 2));
      });
      if (labels) ys.push(P(0, R + m + t + 0.55, 0)[1] + 8, P(cols, R + m + t + 0.55, 0)[1] + 8);
      var x0 = Math.min.apply(null, xs) - 4, y0 = Math.min.apply(null, ys) - 4;
      svg.setAttribute('viewBox', [x0, y0, Math.max.apply(null, xs) + 4 - x0, Math.max.apply(null, ys) + 4 - y0].map(function (v) { return v.toFixed(1); }).join(' '));
      el.appendChild(svg);

      if (reduced()) {
        bars.forEach(function (bar) { bar.h = bar.target; });
        draw();
        return;
      }
      draw();

      var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var last = bars[bars.length - 1].order;
      var tl = null;
      el._ibcStart = function () {
        if (tl) { if (loop) tl.play(); return; }
        tl = anime.createTimeline({ loop: loop, loopDelay: 500, onUpdate: draw })
          .add(bars, {
            h: function (bar) { return bar.target; },
            duration: 1100,
            ease: 'outBack(1.6)',
            delay: function (bar) { return bar.order * stagger + bar.j * 12; }
          });
        if (loop) {
          tl.add(bars, {
            h: 0,
            duration: 500,
            ease: 'in(3)',
            delay: function (bar) { return (last - bar.order) * stagger * 0.4; }
          }, '+=' + hold);
        }
      };
      el._ibcStop = function () { if (tl && loop) tl.pause(); };
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var el = e.target;
          if (!el._ibcStart) return;
          if (e.isIntersecting) el._ibcStart(); else el._ibcStop();
        });
      }, { threshold: 0.25 });
      document.querySelectorAll('[data-isometric-bar-chart]').forEach(function (el) {
        if (el._ibc) return;
        el._ibc = true;
        setup(el);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-isometric-bar-chart data-show-values
     data-labels="Jan,Feb,Mar,Apr"
     data-values="4,7,5,9;3,5,8,6"
     style="width: 420px"></div>