← Generative art

Mandala Drawing Pad

Draw anywhere on the pad and your stroke is repeated round the centre, turned and mirrored like a kaleidoscope, so even a scribble comes out as a mandala. The ink drifts through the rainbow as you draw. Until you start, a pretend pen draws one for you.

Draw anywhere

How to use it

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

Options

Add any of these attributes to the same element.

data-symmetryHow many times each stroke repeats round the centre. Default 8.
data-mirror="false"Turn each copy without mirroring it.
data-widthLine width in px. Default 2.5.
data-colorOne ink colour instead of the drifting rainbow.
data-guides="false"Hide the faint guide lines.
data-autoplayA pretend pen draws mandalas until someone starts drawing.
data-controlsAdd a Clear button.

It works with a mouse, pen or finger; on a touch screen the pad doesn’t scroll the page while you draw on it. Once you’ve drawn, the pretend pen leaves your mandala alone. With reduced motion it shows a finished mandala and you can still draw.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Mandala Drawing Pad: draw anywhere and your stroke is repeated round
    // the centre point, turned and mirrored like a kaleidoscope, so even a
    // scribble comes out as a mandala. The ink slowly shifts colour as you
    // draw.
    // Usage: <div data-mandala-drawing-pad style="height:360px;background:#120a24"></div>
    // Optional: data-symmetry (how many times the stroke repeats round the
    // centre, default 8), data-mirror="false" (turn without mirroring),
    // data-width (line width in px, default 2.5), data-color (one ink colour
    // instead of the shifting rainbow), data-guides="false" (hide the faint
    // guide lines), data-autoplay (a pretend pen draws mandalas until you
    // start drawing, and again after a while left alone), data-controls (add
    // a Clear button).
    var DEFAULTS = { symmetry: 8, width: 2.5, idle: 9000 };

    var css =
      '[data-mandala-drawing-pad]{position:relative;isolation:isolate;overflow:hidden}' +
      '.mdp-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}' +
      '.mdp-live{cursor:crosshair;touch-action:none}' +
      '.mdp-clear{position:absolute;right:12px;bottom:10px;font:500 12px/1 system-ui,sans-serif;padding:7px 12px;border-radius:999px;' +
      'border:1px solid rgba(255,255,255,.25);background:rgba(10,5,20,.55);color:rgba(255,255,255,.9);cursor:pointer}' +
      '.mdp-clear:hover{background:rgba(255,255,255,.14)}' +
      '.mdp-clear:focus-visible{outline:2px solid #fff;outline-offset:2px}';
    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 ink = document.createElement('canvas');
      var over = document.createElement('canvas');
      ink.className = over.className = 'mdp-canvas';
      ink.setAttribute('aria-hidden', 'true');
      over.setAttribute('aria-hidden', 'true');
      el.insertBefore(over, el.firstChild);
      el.insertBefore(ink, over);
      var ictx = ink.getContext('2d'), octx = over.getContext('2d');
      var sym = Math.max(2, Math.min(24, Math.round(+d.symmetry || DEFAULTS.symmetry)));
      var mirror = d.mirror !== 'false';
      var lw = +d.width || DEFAULTS.width;
      var fixed = d.color || null;
      var guides = d.guides !== 'false';
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      var inThumb = !!el.closest('[aria-hidden="true"]');
      var w = 0, h = 0, dpr = 1, R = 100, hue = 270;
      var pen = null, lastPt = null, strokes = 0, idleAt = 0, auto = null, autoGap = 0, fading = 0;
      var autoInk = false, userDrew = false;

      function color() { return fixed || 'hsl(' + Math.round(hue % 360) + ',90%,68%)'; }

      // One segment, repeated round the centre (and mirrored) on the ink canvas.
      function segment(x0, y0, x1, y1) {
        ictx.save();
        ictx.translate(w / 2, h / 2);
        ictx.strokeStyle = color();
        ictx.lineWidth = lw;
        ictx.lineCap = 'round';
        ictx.beginPath();
        for (var i = 0; i < sym; i++) {
          var a = i * Math.PI * 2 / sym, c = Math.cos(a), s = Math.sin(a);
          ictx.moveTo(x0 * c - y0 * s, x0 * s + y0 * c);
          ictx.lineTo(x1 * c - y1 * s, x1 * s + y1 * c);
          if (mirror) {
            ictx.moveTo(x0 * c + y0 * s, x0 * s - y0 * c);
            ictx.lineTo(x1 * c + y1 * s, x1 * s - y1 * c);
          }
        }
        ictx.stroke();
        ictx.restore();
      }

      function drawGuides(px, py) {
        octx.clearRect(0, 0, w, h);
        octx.save();
        octx.translate(w / 2, h / 2);
        if (guides) {
          octx.strokeStyle = 'rgba(255,255,255,.07)';
          octx.lineWidth = 1;
          octx.beginPath();
          for (var i = 0; i < sym; i++) {
            var a = i * Math.PI * 2 / sym;
            octx.moveTo(0, 0);
            octx.lineTo(Math.cos(a) * R, Math.sin(a) * R);
          }
          octx.moveTo(R, 0);
          octx.arc(0, 0, R, 0, Math.PI * 2);
          octx.moveTo(R * 0.5, 0);
          octx.arc(0, 0, R * 0.5, 0, Math.PI * 2);
          octx.stroke();
          octx.fillStyle = 'rgba(255,255,255,.35)';
          octx.beginPath();
          octx.arc(0, 0, 2.5, 0, Math.PI * 2);
          octx.fill();
        }
        // A little dot at every copy of the pen.
        if (px !== undefined) {
          octx.fillStyle = color();
          for (var j = 0; j < sym; j++) {
            var b = j * Math.PI * 2 / sym, c = Math.cos(b), s = Math.sin(b);
            octx.beginPath();
            octx.arc(px * c - py * s, px * s + py * c, lw + 1.5, 0, Math.PI * 2);
            octx.fill();
            if (mirror) {
              octx.beginPath();
              octx.arc(px * c + py * s, px * s - py * c, lw + 1.5, 0, Math.PI * 2);
              octx.fill();
            }
          }
        }
        octx.restore();
      }

      function clear() {
        ictx.clearRect(0, 0, w, h);
        strokes = 0;
        autoInk = false;
      }

      function resize() {
        var nw = el.clientWidth, nh = el.clientHeight;
        if (nw === w && nh === h) return false;
        // Keep what's drawn so far, re-centred, when the box changes size.
        var keep = null;
        if (w && h) { keep = document.createElement('canvas'); keep.width = ink.width; keep.height = ink.height; keep.getContext('2d').drawImage(ink, 0, 0); }
        var ow = w, oh = h;
        w = nw; h = nh;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        [ink, over].forEach(function (c) { c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); });
        ictx.setTransform(dpr, 0, 0, dpr, 0, 0);
        octx.setTransform(dpr, 0, 0, dpr, 0, 0);
        R = Math.min(w, h) / 2 - 8;
        if (keep) ictx.drawImage(keep, (w - ow) / 2, (h - oh) / 2, ow, oh);
        drawGuides();
        return true;
      }

      // The pretend pen: arcs, loops and petals, each a short sweep in polar coordinates.
      function newAuto() {
        var kind = Math.random();
        var r0 = 0.12 + Math.random() * 0.75;
        return {
          r0: r0,
          dr: (Math.random() - 0.3) * (kind < 0.5 ? 0.35 : 0.18),
          a0: Math.random() * Math.PI * 2 / sym,
          da: (Math.random() < 0.5 ? -1 : 1) * (0.25 + Math.random() * 0.9) * Math.PI * 2 / sym,
          waves: kind < 0.5 ? 1 : 2 + Math.floor(Math.random() * 3),
          dur: 900 + Math.random() * 900,
          t: 0
        };
      }

      function autoPoint(s, u) {
        var r = R * Math.max(0.03, Math.min(0.98, s.r0 + s.dr * Math.sin(Math.PI * u * s.waves)));
        var a = s.a0 + s.da * u;
        return [Math.cos(a) * r, Math.sin(a) * r];
      }

      // A finished mandala for still frames and reduced motion.
      function stillMandala() {
        clear();
        for (var k = 0; k < 12; k++) {
          var s = newAuto(), prev = autoPoint(s, 0);
          for (var u = 1; u <= 60; u++) {
            var p = autoPoint(s, u / 60);
            segment(prev[0], prev[1], p[0], p[1]);
            prev = p;
            hue += 0.5;
          }
        }
        autoInk = true;
      }

      resize();
      if (reduced()) {
        if (autoplay) stillMandala();
        if (window.ResizeObserver) new ResizeObserver(function () { resize(); }).observe(el);
      } else if (window.ResizeObserver) new ResizeObserver(function () { resize(); }).observe(el);

      // Real drawing, with mouse, pen or finger.
      if (!inThumb) {
        el.classList.add('mdp-live');
        var local = function (e) {
          var b = el.getBoundingClientRect();
          return [e.clientX - b.left - w / 2, e.clientY - b.top - h / 2];
        };
        el.addEventListener('pointerdown', function (e) {
          if (e.target.closest && e.target.closest('.mdp-clear')) return;
          // The pretend pen's mandala makes way for yours.
          if (autoInk) { clear(); autoInk = false; }
          auto = null; fading = 0; userDrew = true;
          pen = e.pointerId;
          lastPt = local(e);
          try { el.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
          segment(lastPt[0], lastPt[1], lastPt[0] + 0.01, lastPt[1]);
          drawGuides(lastPt[0], lastPt[1]);
          idleAt = performance.now();
          e.preventDefault();
        });
        el.addEventListener('pointermove', function (e) {
          if (pen !== e.pointerId) return;
          var p = local(e);
          segment(lastPt[0], lastPt[1], p[0], p[1]);
          hue += Math.hypot(p[0] - lastPt[0], p[1] - lastPt[1]) * 0.15;
          lastPt = p;
          drawGuides(p[0], p[1]);
          idleAt = performance.now();
        });
        var up = function (e) {
          if (pen !== e.pointerId) return;
          pen = null;
          strokes++;
          drawGuides();
          idleAt = performance.now();
        };
        el.addEventListener('pointerup', up);
        el.addEventListener('pointercancel', up);

        if (d.controls !== undefined) {
          var btn = document.createElement('button');
          btn.type = 'button';
          btn.className = 'mdp-clear';
          btn.textContent = 'Clear';
          btn.addEventListener('click', function () { clear(); auto = null; autoInk = false; userDrew = false; drawGuides(); idleAt = performance.now(); });
          el.appendChild(btn);
        }
      }

      if (reduced() || !autoplay) return;

      // Autoplay: draw stroke after stroke, then fade out and start a new mandala.
      var last = 0;
      idleAt = -Infinity;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(100, now - last) : 16;
          last = now;
          // Once you've drawn, the pretend pen leaves your mandala alone (until Clear).
          if (pen !== null || userDrew || now - idleAt < DEFAULTS.idle) return;
          if (fading) {
            fading -= dt;
            ictx.save();
            ictx.globalCompositeOperation = 'destination-out';
            ictx.fillStyle = 'rgba(0,0,0,' + Math.min(1, dt / 220).toFixed(3) + ')';
            ictx.fillRect(0, 0, w, h);
            ictx.restore();
            if (fading <= 0) { fading = 0; clear(); autoGap = 300; }
            return;
          }
          if (!auto) {
            autoGap -= dt;
            drawGuides();
            if (autoGap > 0) return;
            if (strokes >= 14) { fading = 900; return; }
            auto = newAuto();
            lastPt = autoPoint(auto, 0);
          }
          auto.t = Math.min(1, auto.t + dt / auto.dur);
          // Several small segments per frame keep the curve smooth.
          var prevU = auto.prevU || 0, steps = 4;
          for (var k = 1; k <= steps; k++) {
            var p = autoPoint(auto, prevU + (auto.t - prevU) * k / steps);
            segment(lastPt[0], lastPt[1], p[0], p[1]);
            lastPt = p;
          }
          autoInk = true;
          auto.prevU = auto.t;
          hue += dt * 0.03;
          drawGuides(lastPt[0], lastPt[1]);
          if (auto.t >= 1) { auto = null; strokes++; autoGap = 180 + Math.random() * 300; }
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-mandala-drawing-pad]').forEach(function (el) {
        if (el._mdp) return;
        el._mdp = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-mandala-drawing-pad data-controls style="height: 360px; background: #120a24"></div>