← Generative art

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

  1. Paste the snippet just before </head>.
  2. Add data-koch-snowflake to a box with a height and a dark background.

Options

Add any of these attributes to the same element.

data-depthHow many times the edges sprout, 1 to 7. Default 5.
data-stepTime per step. Default 1100 ms.
data-colorOutline colour. Default #e0f2fe.
data-fillFill colour, or none. Default #38bdf8.
data-spinTurns per minute. Default 1.
data-loopShrink 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.

1 · Snippet
<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>
2 · Your HTML
<div data-koch-snowflake data-loop style="height: 360px; background: #0c1230"></div>