Galton Board
Balls drop from a funnel onto a triangle of pegs and bounce left or right at random at every row, then pile up in the bins below. Nobody can say where one ball will land, but together they build a bell curve, and the dashed line shows the shape they’re heading for. When a bin fills, the floor opens and it starts again.
How to use it
- Paste the snippet just before
</head>. - Add
data-galton-boardto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-loop | When a bin fills, open the floor and start again. |
data-rows | Rows of pegs, 3 to 20. Default 10. |
data-rate | Balls dropped per second. Default 9. |
data-bias | Chance of bouncing right at each peg, 0 to 1. Default 0.5; anything else tips the curve to one side. |
data-colors | Comma-separated colours across the bins. Default #818cf8,#c084fc,#f472b6,#fb923c. |
data-curve="false" | Hide the dashed bell curve. |
Each ball makes one coin toss per row, so the bin it lands in counts its rights. There are far more ways to land in the middle than at the edges (the numbers in Pascal’s triangle), which is where the bell comes from. It’s paused when off screen, and with reduced motion the bins are shown already filled.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Galton Board: balls drop onto a triangle of pegs and bounce left or
// right at random at every row, then pile up in the bins below. No single
// ball is predictable, but together they build a bell curve. When a bin
// fills, the floor opens and it starts again.
// Usage: <div data-galton-board data-loop style="height:360px;background:#1a1033"></div>
// Optional: data-rows (rows of pegs, default 10), data-rate (balls per
// second, default 9), data-colors (comma-separated colours across the
// bins, default "#818cf8,#c084fc,#f472b6,#fb923c"), data-curve="false"
// (hide the dashed bell curve), data-loop (empty the bins and start again
// when one fills), data-bias (chance of bouncing right, 0 to 1, default
// 0.5).
var DEFAULTS = { rows: 10, rate: 9, colors: '#818cf8,#c084fc,#f472b6,#fb923c', bias: 0.5 };
var css =
'[data-galton-board]{position:relative;isolation:isolate;overflow:hidden}' +
'.glt-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function rgb(hex) {
var c = hex.replace('#', '');
if (c.length === 3) c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
var n = parseInt(c, 16);
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'glt-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var rows = Math.max(3, Math.min(20, +d.rows || DEFAULTS.rows));
var rate = +d.rate || DEFAULTS.rate;
var bias = d.bias !== undefined ? +d.bias : DEFAULTS.bias;
var showCurve = d.curve !== 'false';
var loop = d.loop !== undefined && d.loop !== 'false';
var stops = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return rgb(c.trim()); });
var bins = rows + 1, binColor = [];
for (var b = 0; b < bins; b++) {
var f = b / rows * (stops.length - 1), i0 = Math.min(stops.length - 2, Math.floor(f)), k = f - i0;
var c0 = stops[i0], c1 = stops[Math.min(stops.length - 1, i0 + 1)];
binColor.push('rgb(' + Math.round(c0[0] + (c1[0] - c0[0]) * k) + ',' + Math.round(c0[1] + (c1[1] - c0[1]) * k) + ',' +
Math.round(c0[2] + (c1[2] - c0[2]) * k) + ')');
}
// Pascal's triangle: how many paths lead to each bin.
var ways = [1];
for (var r = 0; r < rows; r++) {
var nx = [1];
for (var q = 1; q < ways.length; q++) nx.push(ways[q - 1] + ways[q]);
nx.push(1);
ways = nx;
}
var total = Math.pow(2, rows);
var w = 0, h = 0, dpr = 1;
var sx = 0, sy = 0, cx = 0, top = 0, binTop = 0, floor = 0, br = 0, pr = 0, cap = 0;
var balls = [], counts = [], dropped = 0, spawnAcc = 0, state = 'run', hold = 0, open = 0;
var HOP = 150;
function layout() {
// Funnel and pegs take the top ~60%, bins the bottom ~40%.
var usableH = h - 16, binsH = usableH * 0.4;
sx = Math.min((w - 24) / (bins + 0.6), usableH * 0.6 / ((rows + 1.65) * 0.86));
sy = sx * 0.86;
cx = w / 2;
var totalH = sy * (rows + 1.65) + binsH;
top = (h - totalH) / 2 + sy * 1.9;
binTop = top + sy * (rows - 0.25);
floor = binTop + binsH;
br = sx * 0.2;
pr = Math.max(1.6, sx * 0.08);
cap = Math.max(4, Math.floor((floor - binTop) / (br * 1.75)) * 2);
}
function reset() {
balls = []; counts = []; dropped = 0; state = 'run'; hold = 0; open = 0;
for (var i = 0; i < bins; i++) counts.push(0);
}
function pegX(row, j) { return cx + (j - row / 2) * sx; }
function pegY(row) { return top + row * sy; }
function restY(row) { return pegY(row) - pr - br; }
function spawn() {
// Decide the whole path now, so the ball can wear its bin's colour.
var path = [], j = 0;
for (var i = 0; i < rows; i++) { var right = Math.random() < bias ? 1 : 0; path.push(right); j += right; }
balls.push({ phase: 'hop', s: 0, j: 0, path: path, bin: j, k: 0, x: cx, y: top - sy * 1.2, x0: cx, y0: top - sy * 1.2,
x1: cx, y1: restY(0), stack: -1, vy: 0 });
dropped++;
}
function update(dt) {
if (state === 'run') {
spawnAcc += dt / 1000 * rate;
while (spawnAcc >= 1) { spawnAcc -= 1; spawn(); }
}
for (var i = 0; i < balls.length; i++) {
var b = balls[i];
if (b.stack >= 0 && state !== 'open') continue;
if (state === 'open' && b.stack >= 0) {
b.vy += dt * 0.004; b.y += b.vy * dt;
continue;
}
if (b.phase === 'hop') {
// Hopping from one peg to the next on a little arc.
b.k += dt / (b.s === 0 ? HOP * 1.4 : HOP);
if (b.k >= 1) {
if (b.s === rows) { b.phase = 'fall'; b.vy = 0.12; b.x = b.x1; b.y = b.y1; }
else {
b.s++;
b.x0 = b.x1; b.y0 = b.y1;
b.j += b.path[b.s - 1];
b.x1 = pegX(b.s, b.j);
b.y1 = b.s < rows ? restY(b.s) : binTop;
b.k = 0;
}
}
if (b.phase === 'hop') {
var e = b.k;
b.x = b.x0 + (b.x1 - b.x0) * e;
b.y = b.s === 0 ? b.y0 + (b.y1 - b.y0) * e * e : b.y0 + (b.y1 - b.y0) * e - Math.sin(e * Math.PI) * sy * 0.35;
continue;
}
}
// Dropping into its bin and onto the pile.
var n = counts[b.bin];
var layer = n >> 1, side = n & 1 ? 1 : -1;
var ty = floor - br - layer * br * 1.75;
b.vy += dt * 0.003;
b.y += b.vy * dt;
b.x += (pegX(rows, b.bin) + side * br * 0.98 - b.x) * 0.25;
if (b.y >= ty) {
b.y = ty;
b.x = pegX(rows, b.bin) + side * br * 0.98;
b.stack = n;
counts[b.bin]++;
if (counts[b.bin] >= cap && state === 'run') { state = 'hold'; hold = 0; }
}
}
if (state === 'hold') {
hold += dt;
var moving = false;
for (i = 0; i < balls.length; i++) if (balls[i].stack < 0) { moving = true; break; }
// Let the stragglers land, rest a moment, then open the floor.
if (!moving && hold > 1800 && loop) { state = 'open'; open = 0; }
if (!moving && !loop) balls = balls.filter(function (x) { return x.stack >= 0; });
}
if (state === 'open') {
open += dt;
if (open > 1100) reset();
}
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// Funnel.
ctx.strokeStyle = 'rgba(255,255,255,.28)';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(cx - sx * 1.4, top - sy * 1.9);
ctx.lineTo(cx - br * 1.6, top - sy * 0.9);
ctx.moveTo(cx + sx * 1.4, top - sy * 1.9);
ctx.lineTo(cx + br * 1.6, top - sy * 0.9);
ctx.stroke();
// Pegs.
ctx.fillStyle = 'rgba(255,255,255,.55)';
for (var r = 0; r < rows; r++) {
for (var j = 0; j <= r; j++) {
ctx.beginPath();
ctx.arc(pegX(r, j), pegY(r), pr, 0, Math.PI * 2);
ctx.fill();
}
}
// Bin walls and floor.
var drop = state === 'open' ? Math.min(1, open / 300) : 0;
ctx.strokeStyle = 'rgba(255,255,255,.22)';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (var b = 0; b <= bins; b++) {
var x = pegX(rows, b) - sx / 2;
ctx.moveTo(x, binTop + sy * 0.3);
ctx.lineTo(x, floor);
}
ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,' + (0.4 * (1 - drop)) + ')';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(pegX(rows, 0) - sx / 2, floor + 1);
ctx.lineTo(pegX(rows, rows) + sx / 2, floor + 1);
ctx.stroke();
// Balls.
for (var i = 0; i < balls.length; i++) {
var bl = balls[i];
if (bl.y > h + br) continue;
ctx.fillStyle = binColor[bl.bin];
ctx.beginPath();
ctx.arc(bl.x, bl.y, br, 0, Math.PI * 2);
ctx.fill();
}
// The bell curve the pile is heading for.
if (showCurve && dropped > 8 && state !== 'open') {
var landed = 0;
for (b = 0; b < bins; b++) landed += counts[b];
if (landed > 0) {
ctx.strokeStyle = 'rgba(255,255,255,.75)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
ctx.beginPath();
var steps = bins * 8, mean = rows * bias, sd = Math.sqrt(rows * bias * (1 - bias)) || 0.5;
for (var s = 0; s <= steps; s++) {
var u = -0.5 + s / steps * bins;
var expect = landed * Math.exp(-(u - mean) * (u - mean) / (2 * sd * sd)) / (sd * Math.sqrt(2 * Math.PI));
var y = floor - expect / 2 * br * 1.75;
var xx = pegX(rows, 0) + u * sx;
if (s === 0) ctx.moveTo(xx, y); else ctx.lineTo(xx, y);
}
ctx.stroke();
ctx.setLineDash([]);
}
}
}
function resize() {
var nw = el.clientWidth, nh = el.clientHeight;
if (nw === w && nh === h) return false;
w = nw; h = nh;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
layout();
reset();
return true;
}
// Fill the bins straight away, without the bouncing: for reduced motion.
function settle() {
reset();
var n = Math.round(cap / (ways[rows >> 1] / total) * 0.85);
for (var i = 0; i < n; i++) {
var j = 0;
for (var r = 0; r < rows; r++) j += Math.random() < bias ? 1 : 0;
if (counts[j] >= cap) continue;
var c = counts[j]++;
balls.push({ bin: j, stack: c, x: pegX(rows, j) + (c & 1 ? 1 : -1) * br * 0.98, y: floor - br - (c >> 1) * br * 1.75 });
}
dropped = n;
state = 'done';
}
resize();
if (reduced()) {
settle();
draw();
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) { settle(); draw(); } }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
last = now;
update(dt);
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-galton-board]').forEach(function (el) {
if (el._glt) return;
el._glt = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-galton-board data-loop style="height: 360px; background: #1a1033"></div>