Chaos Game Triangle
Start anywhere. Pick one of three corners at random, jump halfway towards it and leave a dot. Repeat. The first few jumps are shown slowly, then it speeds up, and out of pure chance a perfect Sierpiński triangle builds up, point by point.
How to use it
- Paste the snippet just before
</head>. - Add
data-chaos-game-triangleto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-sides | 3, 5 or 6 corners. Default 3. Five and six jump further, making pentagon and hexagon flakes. |
data-points | How many dots in all. Default fits the box, 8,000 to 70,000. |
data-colors | Comma-separated colours, one per corner. Each dot takes the colour of the corner it jumped towards. Default pink, violet and sky. |
data-loop | Hold the finished triangle, fade it and play again. |
It’s paused when off screen. With reduced motion the finished triangle appears at once.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Chaos Game Triangle: start anywhere, pick one of three corners at
// random, jump halfway towards it and leave a dot. Repeat. The first few
// jumps are shown slowly, then it speeds up, and out of pure chance a
// perfect Sierpinski triangle builds up, point by point. Each dot takes
// the colour of the corner it jumped towards. Paused when off screen.
// Usage: <div data-chaos-game-triangle style="height:360px"></div>
// Optional: data-sides (3, 5 or 6 corners, default 3; five and six jump
// further, so they make pentagon and hexagon flakes), data-points (how
// many dots in all, default fits the box), data-colors (one colour per
// corner, comma-separated, default pink, violet and sky), data-loop (hold
// the finished triangle, fade it and play again).
var DEFAULTS = { sides: 3, colors: '#f472b6,#a78bfa,#38bdf8,#fbbf24,#34d399,#fb923c', hold: 3000 };
// How far each jump goes towards the corner, for a flake with no overlaps.
var RATIO = { 3: 0.5, 5: 0.618, 6: 0.667 };
var css =
'[data-chaos-game-triangle]{position:relative;isolation:isolate;overflow:hidden}' +
'.cgt-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 build(el) {
var d = el.dataset;
var dots = document.createElement('canvas'), over = document.createElement('canvas');
dots.className = over.className = 'cgt-canvas';
dots.setAttribute('aria-hidden', 'true');
over.setAttribute('aria-hidden', 'true');
el.insertBefore(over, el.firstChild);
el.insertBefore(dots, over);
var dctx = dots.getContext('2d'), octx = over.getContext('2d');
var sides = RATIO[+d.sides] ? +d.sides : DEFAULTS.sides, ratio = RATIO[sides];
var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
while (colors.length < sides) colors = colors.concat(colors);
var loop = d.loop !== undefined && d.loop !== 'false';
var w = 0, h = 0, dpr = 1, total = 0, corners = [], size = 1;
var px = 0, py = 0, placed = 0, jump = null, age = 0, fast = 0, phase = 'play', fading = false;
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);
dots.width = over.width = Math.round(w * dpr);
dots.height = over.height = Math.round(h * dpr);
// Corners on a circle, fitted to the box (a triangle sits a little low).
var pad = Math.max(14, Math.min(w, h) * 0.08);
var r = sides === 3 ? Math.min((h - pad * 2) / 1.5, (w - pad * 2) / Math.sqrt(3)) : Math.min(w, h) / 2 - pad;
var cx = w / 2, cy = sides === 3 ? pad + r : h / 2;
corners = [];
for (var i = 0; i < sides; i++) {
var a = -Math.PI / 2 + i * Math.PI * 2 / sides;
corners.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
}
total = +d.points || Math.round(Math.max(8000, Math.min(70000, w * h * 0.32)));
size = Math.max(0.6, Math.min(1.2, Math.sqrt(w * h / total) * 0.55));
return true;
}
function reset() {
dctx.setTransform(1, 0, 0, 1, 0, 0);
dctx.clearRect(0, 0, dots.width, dots.height);
// Start somewhere random inside the box.
px = w * (0.25 + Math.random() * 0.5);
py = h * (0.25 + Math.random() * 0.5);
placed = 0;
age = 0;
fast = 0;
jump = null;
phase = 'play';
}
function dot(x, y, k, big) {
dctx.fillStyle = colors[k];
var s = big ? 3 : size;
dctx.fillRect(x - s / 2, y - s / 2, s, s);
}
// Jump n times in one go.
function burst(n) {
dctx.setTransform(dpr, 0, 0, dpr, 0, 0);
dctx.globalAlpha = 0.85;
for (var i = 0; i < n && placed < total; i++) {
var k = Math.floor(Math.random() * sides);
px += (corners[k][0] - px) * ratio;
py += (corners[k][1] - py) * ratio;
if (placed > 6) dot(px, py, k, false);
placed++;
}
dctx.globalAlpha = 1;
}
// The overlay: corners, and the jump in progress.
function drawOver() {
octx.setTransform(dpr, 0, 0, dpr, 0, 0);
octx.clearRect(0, 0, w, h);
for (var i = 0; i < sides; i++) {
octx.fillStyle = colors[i];
octx.beginPath();
octx.arc(corners[i][0], corners[i][1], 4.5, 0, Math.PI * 2);
octx.fill();
}
if (!jump) return;
var e = 1 - Math.pow(1 - Math.min(1, jump.t), 3);
var x = jump.x0 + (jump.x1 - jump.x0) * e, y = jump.y0 + (jump.y1 - jump.y0) * e;
octx.strokeStyle = colors[jump.k];
octx.globalAlpha = 0.5;
octx.setLineDash([3, 4]);
octx.lineWidth = 1.2;
octx.beginPath();
octx.moveTo(jump.x0, jump.y0);
octx.lineTo(corners[jump.k][0], corners[jump.k][1]);
octx.stroke();
octx.setLineDash([]);
octx.globalAlpha = 1;
octx.fillStyle = '#fff';
octx.beginPath();
octx.arc(x, y, 3.2, 0, Math.PI * 2);
octx.fill();
}
function tick(dt) {
age += dt;
if (phase === 'hold') {
if (loop && age > DEFAULTS.hold && !fading) {
fading = true;
anime.animate(dots, {
opacity: 0, duration: 600, ease: 'inOut(2)',
onComplete: function () { reset(); fading = false; anime.utils.set(dots, { opacity: 1 }); }
});
}
return;
}
// The first dozen jumps one at a time, so you can follow them.
if (placed < 12) {
if (!jump) {
var k = Math.floor(Math.random() * sides);
jump = { k: k, x0: px, y0: py, x1: px + (corners[k][0] - px) * ratio, y1: py + (corners[k][1] - py) * ratio, t: 0 };
}
jump.t += dt / Math.max(100, 440 - placed * 36);
if (jump.t >= 1.25) {
px = jump.x1; py = jump.y1;
dot(px, py, jump.k, true);
placed++;
jump = null;
}
return;
}
// Then faster and faster.
fast += dt;
burst(Math.max(1, Math.min(total / 120, Math.round(Math.pow(fast / 1000, 2.2) * 40))));
if (placed >= total) { phase = 'hold'; age = 0; }
}
resize();
reset();
if (reduced()) {
// The finished triangle.
placed = 12;
burst(total);
drawOver();
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) { reset(); placed = 12; burst(total); drawOver(); } }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) reset(); }).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;
dctx.setTransform(dpr, 0, 0, dpr, 0, 0);
tick(dt);
drawOver();
}
});
// Pause while off screen to save battery.
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-chaos-game-triangle]').forEach(function (el) {
if (el._cgt) return;
el._cgt = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-chaos-game-triangle data-loop style="height: 360px; background: #0f0a1e"></div>
<!-- a pentagon flake: -->
<div data-chaos-game-triangle data-sides="5" style="height: 360px; background: #0f0a1e"></div>