Koch Snowflake
A triangle grows into a snowflake. Step by step, the middle third of every edge pushes out into a smaller triangle, so each edge sprouts smaller and smaller triangles until the outline is lace. Loop it and it melts back down and grows again.
How to use it
- Paste the snippet just before
</head>. - Add
data-koch-snowflaketo a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-depth | How many times the edges sprout, 1 to 7. Default 5. |
data-step | Time per step. Default 1100 ms. |
data-color | Outline colour. Default #e0f2fe. |
data-fill | Fill colour, or none. Default #38bdf8. |
data-spin | Turns per minute. Default 1. |
data-loop | Shrink back step by step, then grow again. |
Every step makes the outline a third longer, so after enough steps it would be endlessly long, yet it never leaves the circle round the first triangle. It’s drawn on a transparent canvas and paused when off screen. With reduced motion it shows the finished flake.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Koch Snowflake: a triangle grows into a snowflake. Each step, the
// middle third of every edge pushes out into a smaller triangle, so each
// edge sprouts smaller and smaller triangles until the outline is lace.
// The flake turns slowly, and with data-loop it melts back down to a
// triangle and grows again. Drawn on a transparent canvas and paused when
// off screen.
// Usage: <div data-koch-snowflake style="height:360px"></div>
// Optional: data-depth (how many times the edges sprout, 1 to 7, default
// 5), data-step (time per step in ms, default 1100), data-color (outline,
// default #e0f2fe), data-fill (inside, default #38bdf8, or "none"),
// data-spin (turns per minute, default 1), data-loop (shrink back step by
// step and grow again).
var DEFAULTS = { depth: 5, step: 1100, color: '#e0f2fe', fill: '#38bdf8', spin: 1, hold: 2600 };
var css =
'[data-koch-snowflake]{position:relative;isolation:isolate;overflow:hidden}' +
'.kch-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; }
// The outline after n steps, as a flat list of x, y on a unit circle.
function base() {
var pts = [];
for (var i = 0; i < 3; i++) {
var a = -Math.PI / 2 + i * Math.PI * 2 / 3;
pts.push(Math.cos(a), Math.sin(a));
}
return pts;
}
// Sprout every edge: a, a + 1/3, peak, a + 2/3. `k` (0 to 1) is how far
// the peaks have pushed out.
function sprout(pts, k) {
var out = [], n = pts.length / 2;
for (var i = 0; i < n; i++) {
var ax = pts[i * 2], ay = pts[i * 2 + 1];
var bx = pts[(i + 1) % n * 2], by = pts[(i + 1) % n * 2 + 1];
var dx = (bx - ax) / 3, dy = (by - ay) / 3;
// Outward, square to the edge (the points run clockwise on screen).
var hx = dy * Math.sqrt(3) / 2, hy = -dx * Math.sqrt(3) / 2;
out.push(ax, ay, ax + dx, ay + dy,
ax + dx * 1.5 + hx * k, ay + dy * 1.5 + hy * k,
ax + dx * 2, ay + dy * 2);
}
return out;
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'kch-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var depth = Math.max(1, Math.min(7, +d.depth || DEFAULTS.depth));
var stepMs = +d.step || DEFAULTS.step;
var color = d.color || DEFAULTS.color;
var fill = (d.fill || DEFAULTS.fill) === 'none' ? null : d.fill || DEFAULTS.fill;
var spin = (d.spin !== undefined ? +d.spin : DEFAULTS.spin) * Math.PI * 2 / 60000;
var loop = d.loop !== undefined && d.loop !== 'false';
var w = 0, h = 0, dpr = 1;
// levels[n] is the finished outline after n steps.
var levels = [base()];
for (var i = 1; i <= depth; i++) levels.push(sprout(levels[i - 1], 1));
var t = 0, age = 0, dir = 1, holding = false, level = 0;
function resize() {
var nw = el.clientWidth, nh = el.clientHeight;
if (nw === w && nh === h) return;
w = nw; h = nh;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
}
// `g` is the growth so far, from 0 (triangle) to depth (finished).
function draw(g) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
var n = Math.min(depth - 1, Math.floor(g)), k = g - n;
if (g >= depth) { n = depth - 1; k = 1; }
k = k * k * (3 - 2 * k);
var pts = g <= 0 ? levels[0] : sprout(levels[n], k);
// The finished flake reaches 2/sqrt(3) of the triangle's radius.
var r = Math.min(w, h) / 2 * 0.86 / (2 / Math.sqrt(3));
var rot = t * spin, c = Math.cos(rot), s = Math.sin(rot), cx = w / 2, cy = h / 2;
ctx.beginPath();
for (var i = 0; i < pts.length; i += 2) {
var x = cx + (pts[i] * c - pts[i + 1] * s) * r, y = cy + (pts[i] * s + pts[i + 1] * c) * r;
if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y);
}
ctx.closePath();
if (fill) {
var grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r * 1.15);
grad.addColorStop(0, fill);
grad.addColorStop(1, 'rgba(255,255,255,0.05)');
ctx.globalAlpha = 0.35;
ctx.fillStyle = fill;
ctx.fill();
ctx.globalAlpha = 0.5;
ctx.fillStyle = grad;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.lineJoin = 'round';
ctx.strokeStyle = color;
ctx.lineWidth = Math.max(1, 2.4 - n * 0.3);
ctx.shadowColor = color;
ctx.shadowBlur = 8;
ctx.stroke();
ctx.shadowBlur = 0;
}
function tick(dt) {
t += dt;
age += dt;
if (holding) {
if (age > (dir > 0 ? DEFAULTS.hold : 700)) {
holding = false;
age = 0;
if (loop) dir = -dir; else dir = 0;
}
return;
}
if (!dir) return;
level += dir * dt / stepMs;
if (level >= depth) { level = depth; holding = true; age = 0; }
else if (level <= 0) { level = 0; holding = true; age = 0; }
}
// Steps grow, then pause a moment before the next one.
function growth() {
var n = Math.floor(level), k = level - n;
return n + Math.min(1, k / 0.75);
}
resize();
if (reduced()) {
draw(depth);
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(depth); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(resize).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;
tick(dt);
draw(growth());
}
});
// 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-koch-snowflake]').forEach(function (el) {
if (el._kch) return;
el._kch = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-koch-snowflake data-loop style="height: 360px; background: #0c1230"></div>