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
- Paste the snippet just before
</head>. - Add
data-mandala-drawing-padto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-symmetry | How many times each stroke repeats round the centre. Default 8. |
data-mirror="false" | Turn each copy without mirroring it. |
data-width | Line width in px. Default 2.5. |
data-color | One ink colour instead of the drifting rainbow. |
data-guides="false" | Hide the faint guide lines. |
data-autoplay | A pretend pen draws mandalas until someone starts drawing. |
data-controls | Add 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.
<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><div data-mandala-drawing-pad data-controls style="height: 360px; background: #120a24"></div>