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
- Paste the snippet just before
</head>. - Add
data-isometric-bar-chartto a box with a width, and list your numbers indata-values, separated by commas. - For more than one row of bars, separate the rows with semicolons.
Options
Add any of these attributes to the same element.
data-values | The numbers, e.g. 3,6,4;5,2,7 for two rows of three. Required. |
data-labels | Names for the columns, separated by commas, written along the front of the floor. |
data-show-values | Print each number on top of its bar. |
data-colors | One colour per row, separated by commas. Default violet, pink and sky blue. |
data-max | The value of the tallest possible bar. Default the largest value. |
data-height | Height of the tallest bar, in floor tiles. Default 3. |
data-loop | Sink the bars and raise them again. |
data-hold | How long the bars stand before sinking, when looping. Default 2600 ms. |
data-stagger | Gap 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.
<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><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>