Harmonograph
A pendulum drawing machine. Two pendulums swing the pen from side to side and two more swing it up and down, each at a slightly different rate, while friction slowly dies them down. The pen’s loops drift, shrink and weave into a fine spiral. Loop it and it starts again with new rates.
How to use it
- Paste the snippet just before
</head>. - Add
data-harmonographto a box with a height and a light background.
Options
Add any of these attributes to the same element.
data-colors | The ink’s colour at the start and at the end of the drawing. Default #7c3aed,#db2777. |
data-duration | How long each drawing takes. Default 11000 ms. |
data-loop | Hold the drawing, fade it and draw another with new rates. |
The rates sit close to simple ratios like 2 : 3, but a hair off, which is what makes the loops creep round. Victorian parlours had real ones, with a pen on a lever swung by pendulums. It’s drawn on a transparent canvas and paused when off screen. With reduced motion it shows a finished drawing.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Harmonograph: a pendulum drawing machine. Two pendulums swing the pen
// side to side and two more swing it up and down, each at a slightly
// different rate, while friction slowly dies them down. The pen traces
// loops that drift and shrink into a fine, woven spiral. With data-loop it
// holds the drawing, fades it and sets the pendulums swinging again with
// new rates. Drawn on a transparent canvas and paused when off screen.
// Usage: <div data-harmonograph style="height:360px"></div>
// Optional: data-colors (the ink's colour at the start and end of the
// drawing, default "#7c3aed,#db2777"), data-duration (how long each
// drawing takes, ms, default 11000), data-loop (draw another with new
// rates).
var DEFAULTS = { colors: '#7c3aed,#db2777', duration: 11000, hold: 2400 };
// How far the drawing runs: until the swings are about 4% of where they started.
var FADE_TO = 0.04;
var css =
'[data-harmonograph]{position:relative;isolation:isolate;overflow:hidden}' +
'.hmg-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 rand(a, b) { return a + Math.random() * (b - a); }
function pick(list) { return list[Math.floor(Math.random() * list.length)]; }
function rgb(hex) {
var n = parseInt(hex.trim().replace('#', ''), 16);
return [n >> 16, n >> 8 & 255, n & 255];
}
// Four pendulums: [amplitude, rate, phase, damping] each.
function pendulums() {
var pairs = [[2, 3], [3, 2], [3, 4], [2, 2], [3, 3], [1, 3], [3, 1], [4, 3]];
var f = pick(pairs), g = pick(pairs), damp = rand(0.0035, 0.006), split = rand(0.45, 0.65);
function one(a, rate) { return [a, rate + rand(-0.015, 0.015), rand(0, Math.PI * 2), damp * rand(0.7, 1.3)]; }
return [one(split, f[0]), one(1 - split, g[0]), one(split, f[1]), one(1 - split, g[1])];
}
function build(el) {
var d = el.dataset;
var ink = document.createElement('canvas'), over = document.createElement('canvas');
ink.className = over.className = 'hmg-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 cs = (d.colors || DEFAULTS.colors).split(',').map(rgb);
if (cs.length < 2) cs.push(cs[0]);
var duration = +d.duration || DEFAULTS.duration;
var loop = d.loop !== undefined && d.loop !== 'false';
var w = 0, h = 0, dpr = 1, rx = 1, ry = 1, cx = 0, cy = 0;
var P = pendulums(), t = 0, end = 1, phase = 'draw', age = 0, 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);
ink.width = over.width = Math.round(w * dpr);
ink.height = over.height = Math.round(h * dpr);
var pad = Math.max(12, Math.min(w, h) * 0.07);
ry = h / 2 - pad;
rx = Math.min(w / 2 - pad, ry * 1.35);
cx = w / 2;
cy = h / 2;
return true;
}
function start() {
ictx.setTransform(1, 0, 0, 1, 0, 0);
ictx.clearRect(0, 0, ink.width, ink.height);
P = pendulums();
// Run until the slowest-dying pendulum has nearly stopped.
var slow = Math.min(P[0][3], P[1][3], P[2][3], P[3][3]);
end = Math.log(1 / FADE_TO) / slow;
t = 0;
phase = 'draw';
age = 0;
}
function swing(p, u) { return p[0] * Math.sin(p[1] * u + p[2]) * Math.exp(-p[3] * u); }
function pos(u) {
return [cx + rx * (swing(P[0], u) + swing(P[1], u)), cy + ry * (swing(P[2], u) + swing(P[3], u))];
}
// Ink the line from time a to b, in a colour that drifts as it goes.
function trace(a, b) {
var k = a / end, c = [0, 1, 2].map(function (i) { return Math.round(cs[0][i] + (cs[1][i] - cs[0][i]) * k); });
ictx.setTransform(dpr, 0, 0, dpr, 0, 0);
ictx.strokeStyle = 'rgb(' + c.join(',') + ')';
ictx.globalAlpha = 0.5;
ictx.lineWidth = Math.max(0.7, Math.min(1.1, Math.min(w, h) / 360));
ictx.lineJoin = 'round';
ictx.beginPath();
var q = pos(a);
ictx.moveTo(q[0], q[1]);
for (var u = a; u < b;) {
u = Math.min(b, u + 0.02);
q = pos(u);
ictx.lineTo(q[0], q[1]);
}
ictx.stroke();
ictx.globalAlpha = 1;
}
function drawOver() {
octx.setTransform(dpr, 0, 0, dpr, 0, 0);
octx.clearRect(0, 0, w, h);
if (phase !== 'draw') return;
var q = pos(t);
octx.fillStyle = 'rgba(40,20,70,.85)';
octx.beginPath();
octx.arc(q[0], q[1], 3, 0, Math.PI * 2);
octx.fill();
octx.strokeStyle = 'rgba(40,20,70,.25)';
octx.lineWidth = 6;
octx.beginPath();
octx.arc(q[0], q[1], 7, 0, Math.PI * 2);
octx.stroke();
}
function tick(dt) {
age += dt;
if (phase === 'draw') {
var b = Math.min(end, t + end * dt / duration);
trace(t, b);
t = b;
if (t >= end) { phase = 'hold'; age = 0; }
} else if (phase === 'hold' && loop && !fading && age > DEFAULTS.hold) {
fading = true;
anime.animate(ink, {
opacity: 0, duration: 800, ease: 'inOut(2)',
onComplete: function () { start(); fading = false; anime.utils.set(ink, { opacity: 1 }); }
});
}
}
function still() {
var keep = P;
start();
P = keep;
for (var u = 0; u < end; u += 20) trace(u, Math.min(end, u + 20));
phase = 'hold';
drawOver();
}
resize();
start();
if (reduced()) {
still();
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) still(); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(function () {
if (!resize()) return;
// Redraw what's done so far at the new size.
var upto = t;
ictx.setTransform(1, 0, 0, 1, 0, 0);
ictx.clearRect(0, 0, ink.width, ink.height);
for (var u = 0; u < upto; u += 20) trace(u, Math.min(upto, u + 20));
}).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);
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-harmonograph]').forEach(function (el) {
if (el._hmg) return;
el._hmg = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-harmonograph data-loop style="height: 360px; background: #fbf7ef"></div>