Spirograph
A toothed gear rolls round the inside of a ring and a pen through one of its holes traces a looping curve. When the curve meets itself, a new gear and a new ink colour start over the top, and three curves layer up into a pattern, just like the toy.
How to use it
- Paste the snippet just before
</head>. - Add
data-spirographto a box with a height and a light background.
Options
Add any of these attributes to the same element.
data-colors | Comma-separated ink colours, one per curve. Default violet, pink, teal and orange. |
data-curves | How many curves to layer up. Default 3. |
data-speed | Speed multiplier. Default 1. |
data-gears="false" | Hide the ring and gear, and just draw. |
data-loop | Hold the finished pattern, fade it and start a new one. |
The ring has 96 teeth. A gear with 60 teeth shares a factor of 12 with it, so it rolls round the ring 5 times (60 ÷ 12) before the pen gets back to where it started; gears that share smaller factors go round more times and draw more loops. It’s drawn on a transparent canvas and paused when off screen. With reduced motion it shows a finished pattern.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Spirograph: a toothed gear rolls round the inside of a ring, and a pen
// through one of its holes traces a looping curve. When the curve closes,
// a new gear and a new colour draw over it, and the curves layer up into
// a pattern. Drawn on a transparent canvas and paused when off screen.
// Usage: <div data-spirograph style="height:360px"></div>
// Optional: data-colors (comma-separated pen colours, default violet,
// pink, teal and orange), data-curves (how many curves to layer up before
// starting again, default 3), data-speed (speed multiplier, default 1),
// data-gears="false" (hide the gears and just draw), data-loop (once the
// pattern is done, hold it, fade it and start a new one).
var DEFAULTS = { colors: '#7c3aed,#db2777,#0d9488,#ea580c', curves: 3, speed: 1, hold: 2200 };
// Ring of 96 teeth; each gear: its teeth, and how far out the pen hole is (0..1).
var RING = 96;
var GEARS = [[60, 0.82], [42, 0.86], [52, 0.7], [75, 0.62], [30, 0.95], [63, 0.78], [36, 0.6], [45, 0.9]];
var css =
'[data-spirograph]{position:relative;isolation:isolate;overflow:hidden}' +
'.spg-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 gcd(a, b) { return b ? gcd(b, a % b) : a; }
function build(el) {
var d = el.dataset;
var ink = document.createElement('canvas'), over = document.createElement('canvas');
ink.className = over.className = 'spg-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 colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
var perPattern = Math.max(1, +d.curves || DEFAULTS.curves);
var speed = +d.speed || DEFAULTS.speed;
var showGears = d.gears !== 'false';
var loop = d.loop !== undefined && d.loop !== 'false';
var w = 0, h = 0, dpr = 1, unit = 1, cx = 0, cy = 0;
// The curve being drawn: gear teeth r, pen hole p, how far round (theta) out of end.
var r = 60, p = 0.8, theta = 0, end = 1, color = colors[0], lap = 0;
var drawn = 0, deal = Math.floor(Math.random() * GEARS.length), turn = 0;
var 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);
cx = w / 2; cy = h / 2;
// Ring radius in px, divided by its teeth.
unit = (Math.min(w, h) / 2 - Math.max(10, Math.min(w, h) * 0.06)) / RING;
return true;
}
// Pen and gear centre at angle t, in px.
function pen(t) {
var k = (RING - r) / r, R0 = (RING - r) * unit, pd = r * p * unit;
return [cx + R0 * Math.cos(t) + pd * Math.cos(k * t), cy + R0 * Math.sin(t) - pd * Math.sin(k * t)];
}
function nextCurve() {
var g = GEARS[deal % GEARS.length];
deal++;
r = g[0];
p = g[1];
lap = r / gcd(RING, r);
end = Math.PI * 2 * lap;
// Start each curve somewhere new round the ring.
turn = Math.random() * Math.PI * 2;
theta = 0;
color = colors[drawn % colors.length];
phase = 'draw';
}
function clearInk() {
ictx.setTransform(1, 0, 0, 1, 0, 0);
ictx.clearRect(0, 0, ink.width, ink.height);
drawn = 0;
nextCurve();
}
// Ink the curve from angle a to b.
function trace(a, b) {
ictx.setTransform(dpr, 0, 0, dpr, 0, 0);
ictx.strokeStyle = color;
ictx.globalAlpha = 0.85;
ictx.lineWidth = Math.max(1, Math.min(1.6, unit * 0.9));
ictx.lineCap = ictx.lineJoin = 'round';
ictx.beginPath();
var q = pen(a + turn);
ictx.moveTo(q[0], q[1]);
for (var t = a; t < b;) {
t = Math.min(b, t + 0.02);
q = pen(t + turn);
ictx.lineTo(q[0], q[1]);
}
ictx.stroke();
ictx.globalAlpha = 1;
}
function teeth(x, y, rad, n, rot, inward) {
octx.beginPath();
for (var i = 0; i < n; i++) {
var a = rot + i * Math.PI * 2 / n, c = Math.cos(a), s = Math.sin(a), t = inward ? -3 : 3;
octx.moveTo(x + c * rad, y + s * rad);
octx.lineTo(x + c * (rad + t), y + s * (rad + t));
}
octx.stroke();
}
// The overlay: the ring, the rolling gear and the pen.
function drawOver() {
octx.setTransform(dpr, 0, 0, dpr, 0, 0);
octx.clearRect(0, 0, w, h);
if (!showGears || phase !== 'draw') return;
var R = RING * unit, k = (RING - r) / r, a = theta + turn;
var gx = cx + (RING - r) * unit * Math.cos(a), gy = cy + (RING - r) * unit * Math.sin(a);
octx.strokeStyle = 'rgba(60,40,90,.28)';
octx.lineWidth = 1;
octx.beginPath();
octx.arc(cx, cy, R, 0, Math.PI * 2);
octx.stroke();
if (R > 60) teeth(cx, cy, R, RING, 0, false);
// The gear, turning backwards as it rolls.
octx.fillStyle = 'rgba(124,58,237,.08)';
octx.strokeStyle = 'rgba(60,40,90,.4)';
octx.beginPath();
octx.arc(gx, gy, r * unit, 0, Math.PI * 2);
octx.fill();
octx.stroke();
if (R > 60) teeth(gx, gy, r * unit, r, -k * a, true);
var q = pen(a);
octx.beginPath();
octx.moveTo(gx, gy);
octx.lineTo(q[0], q[1]);
octx.stroke();
octx.fillStyle = 'rgba(60,40,90,.45)';
octx.beginPath();
octx.arc(gx, gy, 2, 0, Math.PI * 2);
octx.fill();
octx.fillStyle = color;
octx.beginPath();
octx.arc(q[0], q[1], 3.5, 0, Math.PI * 2);
octx.fill();
octx.strokeStyle = '#fff';
octx.lineWidth = 1.5;
octx.stroke();
}
function tick(dt) {
age += dt;
if (phase === 'draw') {
// Every curve takes 3 to 7 seconds, however many laps it has.
var ms = Math.min(7000, 2600 + lap * 220) / speed;
var b = Math.min(end, theta + end * dt / ms);
trace(theta, b);
theta = b;
if (theta >= end) {
drawn++;
if (drawn < perPattern) nextCurve();
else { phase = 'hold'; age = 0; }
}
} else if (phase === 'hold' && loop && !fading && age > DEFAULTS.hold) {
fading = true;
anime.animate(ink, {
opacity: 0, duration: 700, ease: 'inOut(2)',
onComplete: function () { clearInk(); fading = false; anime.utils.set(ink, { opacity: 1 }); }
});
}
}
// Draw the whole pattern at once (reduced motion).
function still() {
clearInk();
for (var i = 0; i < perPattern; i++) {
if (i) nextCurve();
trace(0, end);
drawn++;
}
phase = 'hold';
drawOver();
}
resize();
clearInk();
if (reduced()) {
still();
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) still(); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) clearInk(); }).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-spirograph]').forEach(function (el) {
if (el._spg) return;
el._spg = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-spirograph data-loop style="height: 360px; background: #fdf8f0"></div>