Spirograph: How to Create Mesmerizing Spiral Patterns With Code
The Spirograph is one of those toys that never stops being fascinating. Stick a small gear inside a big ring, poke a pen through one of the holes, and trace. The pen draws a curve that loops and spirals into intricate, symmetrical patterns — flowers, stars, atomic orbitals. Hasbro sold millions of them starting in 1965, but the math behind them goes back to the 19th century.
These curves are called hypotrochoids and epitrochoids. The parametric equations are simple. The results are anything but. In this guide, we'll build 8 interactive spirograph programs from scratch using JavaScript and the HTML Canvas API. No libraries. No frameworks. Just math and pixels.
1. Basic hypotrochoid — the classic spirograph curve
A hypotrochoid is the curve traced by a point attached to a small circle rolling inside a larger circle. Three parameters control everything: R (outer radius), r (inner radius), and d (pen distance from center of inner circle). The parametric equations:
- x(t) = (R - r) cos(t) + d cos((R - r)t / r)
- y(t) = (R - r) sin(t) - d sin((R - r)t / r)
When d equals r, you get a hypocycloid — the pen is on the rim of the inner circle. When d is less than r, the curve never touches the outer circle. When d is greater than r, the loops extend beyond the inner circle's path.
const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const cx = 250, cy = 250;
const R = 200, r = 75, d = 60;
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 500, 500);
ctx.beginPath();
const steps = 10000;
const tMax = 2 * Math.PI * r / gcd(R, r);
function gcd(a, b) { return b === 0 ? a : gcd(b, a % b); }
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * tMax;
const x = cx + (R - r) * Math.cos(t) + d * Math.cos((R - r) * t / r);
const y = cy + (R - r) * Math.sin(t) - d * Math.sin((R - r) * t / r);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.strokeStyle = 'hsl(280, 80%, 65%)';
ctx.lineWidth = 1.2;
ctx.stroke();
The gcd function matters. It tells us exactly when the curve closes — the pen returns to its starting point after the inner gear completes R / gcd(R, r) revolutions. For R=200 and r=75, gcd is 25, so the curve closes after 8 revolutions of the inner gear. Without calculating this, you'd either draw too little (incomplete pattern) or too much (retracing lines).
2. Epitrochoid — rolling on the outside
Flip the setup: roll the small circle around the outside of the big one. Now you get an epitrochoid. The equations are almost identical, with addition instead of subtraction:
- x(t) = (R + r) cos(t) - d cos((R + r)t / r)
- y(t) = (R + r) sin(t) - d sin((R + r)t / r)
Epitrochoids tend to produce pointier, more star-like patterns. The Wankel rotary engine's combustion chamber? That's an epitrochoid.
const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const cx = 250, cy = 250;
function gcd(a, b) { return b === 0 ? a : gcd(b, a % b); }
function drawEpitrochoid(R, r, d, color) {
const steps = 10000;
const tMax = 2 * Math.PI * r / gcd(R, r);
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * tMax;
const x = cx + (R + r) * Math.cos(t) - d * Math.cos((R + r) * t / r);
const y = cy + (R + r) * Math.sin(t) - d * Math.sin((R + r) * t / r);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.strokeStyle = color;
ctx.lineWidth = 0.8;
ctx.stroke();
}
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 500, 500);
// Three epitrochoids nested inside each other
drawEpitrochoid(80, 30, 25, 'hsl(340, 85%, 60%)');
drawEpitrochoid(80, 50, 40, 'hsl(200, 85%, 60%)');
drawEpitrochoid(80, 70, 55, 'hsl(120, 85%, 60%)');
Layering multiple epitrochoids with different gear ratios creates complex interference patterns. The three curves here never intersect in quite the same way — each gear ratio produces a different number of petals, and the overlay builds visual depth.
3. Spirograph pattern grid — gear ratio explorer
The character of a spirograph pattern depends almost entirely on the ratio R/r. Integer ratios produce simple patterns with few petals. Ratios close to integers produce dense, almost-closed curves. Irrational ratios never close at all — they'd fill the entire ring if you traced long enough.
const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 500, 500);
function gcd(a, b) { return b === 0 ? a : gcd(b, a % b); }
const configs = [
{ R: 100, r: 50, label: '2:1' },
{ R: 100, r: 33, label: '3:1' },
{ R: 100, r: 25, label: '4:1' },
{ R: 100, r: 60, label: '5:3' },
{ R: 100, r: 40, label: '5:2' },
{ R: 100, r: 70, label: '10:7' },
{ R: 100, r: 37, label: '100:37' },
{ R: 100, r: 63, label: '100:63' },
{ R: 100, r: 43, label: '100:43' },
];
const cols = 3, cellW = 500 / cols;
configs.forEach((cfg, idx) => {
const col = idx % cols, row = Math.floor(idx / cols);
const cx = col * cellW + cellW / 2;
const cy = row * cellW + cellW / 2;
const scale = 0.55;
const R = cfg.R * scale, r = cfg.r * scale, d = r * 0.8;
const steps = 8000;
const g = gcd(Math.round(R * 10), Math.round(r * 10));
const tMax = 2 * Math.PI * Math.round(r * 10) / g;
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * tMax;
const x = cx + (R - r) * Math.cos(t) + d * Math.cos((R - r) * t / r);
const y = cy + (R - r) * Math.sin(t) - d * Math.sin((R - r) * t / r);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = `hsl(${idx * 40}, 75%, 60%)`;
ctx.lineWidth = 0.7;
ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '11px monospace';
ctx.textAlign = 'center';
ctx.fillText(cfg.label, cx, cy + cellW / 2 - 8);
});
The 2:1 ratio gives you a simple ellipse — barely interesting. 3:1 gives a deltoid (three cusps). 5:3 produces five petals. But look at the bottom row: 100:37 and 100:43 are dense, intricate patterns that take dozens of revolutions to close. The higher the least common multiple of R and r, the more complex the pattern.
4. Animated spirograph drawing — watch the gears turn
Half the magic of a real Spirograph is watching the pen trace the curve in real time. The gear rolling, the pen moving — you can see why the pattern emerges. This example animates the drawing process and shows the rolling gear.
const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const cx = 250, cy = 250;
const R = 180, r = 67, d = 52;
function gcd(a, b) { return b === 0 ? a : gcd(b, a % b); }
const tMax = 2 * Math.PI * r / gcd(R, r);
let t = 0;
const speed = 0.03;
const trail = [];
function draw() {
ctx.fillStyle = 'rgba(10, 10, 26, 0.08)';
ctx.fillRect(0, 0, 500, 500);
// Outer circle (fixed ring)
ctx.beginPath();
ctx.arc(cx, cy, R, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.stroke();
// Inner circle position
const gearX = cx + (R - r) * Math.cos(t);
const gearY = cy + (R - r) * Math.sin(t);
ctx.beginPath();
ctx.arc(gearX, gearY, r, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.stroke();
// Pen position
const penX = cx + (R - r) * Math.cos(t) + d * Math.cos((R - r) * t / r);
const penY = cy + (R - r) * Math.sin(t) - d * Math.sin((R - r) * t / r);
// Line from gear center to pen
ctx.beginPath();
ctx.moveTo(gearX, gearY);
ctx.lineTo(penX, penY);
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.stroke();
// Pen dot
ctx.beginPath();
ctx.arc(penX, penY, 3, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
// Trail
trail.push({ x: penX, y: penY });
if (trail.length > 1) {
ctx.beginPath();
ctx.moveTo(trail[0].x, trail[0].y);
for (let i = 1; i < trail.length; i++) {
ctx.lineTo(trail[i].x, trail[i].y);
}
const hue = (t * 30) % 360;
ctx.strokeStyle = `hsl(${hue}, 80%, 60%)`;
ctx.lineWidth = 1.5;
ctx.stroke();
}
t += speed;
if (t < tMax + speed) {
requestAnimationFrame(draw);
}
}
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 500, 500);
draw();
The fading background creates a ghost trail — you can see the last few seconds of drawing while the older portions fade. The hue shifts as the angle increases, so the color encodes time. When the pattern completes, the animation stops. You can see exactly how the inner gear's rotation maps to the petals of the final pattern.
5. Multi-gear spirograph — compound roulettes
Real spirograph sets come with multiple gears. What if you chain them — a gear rolling inside a gear rolling inside the outer ring? The math extends naturally: each additional gear adds its own frequency and amplitude to the parametric sum.
const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const cx = 250, cy = 250;
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 500, 500);
// Chain of gears: each {r, d} pair adds a rolling circle
function multiGear(gears, t) {
let x = 0, y = 0;
let outerR = 180;
gears.forEach((gear, i) => {
const sign = i % 2 === 0 ? 1 : -1;
const freq = sign * (outerR - gear.r) / gear.r;
x += (outerR - gear.r) * Math.cos(t) + gear.d * Math.cos(freq * t);
y += (outerR - gear.r) * Math.sin(t) - gear.d * Math.sin(freq * t);
outerR = gear.r;
});
return { x: cx + x / gears.length, y: cy + y / gears.length };
}
const gearSets = [
[{ r: 70, d: 55 }, { r: 30, d: 20 }],
[{ r: 80, d: 60 }, { r: 45, d: 35 }, { r: 20, d: 15 }],
[{ r: 60, d: 50 }, { r: 40, d: 30 }],
];
const colors = ['hsl(320, 80%, 55%)', 'hsl(180, 80%, 55%)', 'hsl(50, 80%, 55%)'];
gearSets.forEach((gears, gi) => {
ctx.beginPath();
const steps = 15000;
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * Math.PI * 40;
const p = multiGear(gears, t);
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
}
ctx.strokeStyle = colors[gi];
ctx.lineWidth = 0.5;
ctx.globalAlpha = 0.8;
ctx.stroke();
});
ctx.globalAlpha = 1;
Compound roulettes can produce patterns that look nothing like single-gear spirographs. The three-gear chain (middle curve) generates a fractal-like boundary with self-similar structure at different scales. This is the mathematical principle behind Fourier series visualization — any closed curve can be approximated by enough nested rotating circles.
6. Guilloché patterns — the art of security printing
Guilloché patterns are the fine-line geometric designs on banknotes, certificates, and watch dials. They're generated by mechanical lathes called rose engines — which are essentially spirographs with adjustable parameters. The overlapping engraved lines create moiré effects that are almost impossible to counterfeit.
const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 500, 500);
const cx = 250, cy = 250;
function guilloche(n1, n2, n3, amp1, amp2, amp3, phase, color) {
ctx.beginPath();
const steps = 3000;
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * Math.PI * 2;
const r = 150 + amp1 * Math.sin(n1 * t) + amp2 * Math.sin(n2 * t + phase) + amp3 * Math.cos(n3 * t);
const x = cx + r * Math.cos(t);
const y = cy + r * Math.sin(t);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = color;
ctx.lineWidth = 0.6;
ctx.stroke();
}
// Layer multiple guilloché rings with slight parameter shifts
for (let layer = 0; layer < 30; layer++) {
const phase = layer * 0.12;
const hue = 160 + layer * 3;
guilloche(7, 13, 19, 20 + layer * 0.8, 15, 10, phase, `hsla(${hue}, 60%, 50%, 0.6)`);
}
// Inner ring with different frequencies
for (let layer = 0; layer < 20; layer++) {
const phase = layer * 0.15;
const hue = 30 + layer * 4;
guilloche(5, 11, 17, 12 + layer * 0.5, 8, 6, phase, `hsla(${hue}, 70%, 55%, 0.5)`);
}
Fifty overlapping curves, each shifted by a tiny phase offset. The result: a moiré interference pattern that shimmers and shifts depending on your screen resolution and zoom level. This is the same principle used in anti-counterfeiting on Euro banknotes — the complexity arises from simplicity repeated with variation.
7. Interactive spirograph — drag to change parameters
The best way to understand spirograph math is to play with it. This example lets you drag the mouse to change the inner gear ratio and pen distance in real time. Move left-right to change the gear ratio, up-down to change the pen distance.
const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const cx = 250, cy = 250;
const R = 180;
let r = 67, d = 52;
let mouseDown = false;
function gcd(a, b) { a = Math.round(a); b = Math.round(b); return b === 0 ? Math.abs(a) : gcd(b, a % b); }
function drawSpirograph() {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 500, 500);
const g = gcd(R, r);
const tMax = 2 * Math.PI * r / (g || 1);
const steps = Math.max(5000, Math.ceil(tMax * 500));
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * tMax;
const x = cx + (R - r) * Math.cos(t) + d * Math.cos((R - r) * t / r);
const y = cy + (R - r) * Math.sin(t) - d * Math.sin((R - r) * t / r);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
const hue = (r * 3 + d * 2) % 360;
ctx.strokeStyle = `hsl(${hue}, 80%, 60%)`;
ctx.lineWidth = 1;
ctx.stroke();
// Labels
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '13px monospace';
ctx.textAlign = 'left';
ctx.fillText(`R=${R} r=${Math.round(r)} d=${Math.round(d)} ratio=${R}:${Math.round(r)}`, 12, 24);
ctx.fillText('Drag: X = gear ratio, Y = pen distance', 12, 488);
}
canvas.addEventListener('mousedown', () => mouseDown = true);
canvas.addEventListener('mouseup', () => mouseDown = false);
canvas.addEventListener('mouseleave', () => mouseDown = false);
canvas.addEventListener('mousemove', (e) => {
if (!mouseDown) return;
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height;
r = Math.max(5, Math.round(mx * 170 + 10));
d = Math.max(1, Math.round(my * 150 + 5));
drawSpirograph();
});
drawSpirograph();
Drag slowly across the canvas. You'll see the pattern morph continuously — some ratios produce clean, closed curves with just a few petals. Others produce dense, almost-chaotic tangles. The transition between them is smooth: a small change in gear ratio can completely transform the character of the pattern. That sensitivity is part of what makes spirographs so endlessly varied.
8. Generative spirograph art — layered composition
This final example combines everything into an art generator. Multiple spirograph curves with different parameters, colors, and blend modes layer into a single composition. Each run produces a unique piece.
const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 500, 500);
const cx = 250, cy = 250;
function gcd(a, b) { a = Math.round(a); b = Math.round(b); return b === 0 ? Math.abs(a) : gcd(b, a % b); }
// Deterministic random for reproducible art
function r(seed) {
let x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
}
function randomSpirograph(seed) {
const R = 150 + r(seed) * 50;
const rr = 10 + r(seed + 1) * 130;
const d = 5 + r(seed + 2) * rr;
const hue = r(seed + 3) * 360;
const sat = 50 + r(seed + 4) * 40;
const light = 45 + r(seed + 5) * 25;
const alpha = 0.15 + r(seed + 6) * 0.45;
return { R, r: rr, d, hue, sat, light, alpha };
}
ctx.globalCompositeOperation = 'screen';
const numCurves = 15;
for (let c = 0; c < numCurves; c++) {
const p = randomSpirograph(c * 7 + 42);
const g = gcd(Math.round(p.R), Math.round(p.r));
const tMax = 2 * Math.PI * Math.round(p.r) / (g || 1);
const steps = Math.min(20000, Math.max(5000, Math.ceil(tMax * 300)));
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * tMax;
const x = cx + (p.R - p.r) * Math.cos(t) + p.d * Math.cos((p.R - p.r) * t / p.r);
const y = cy + (p.R - p.r) * Math.sin(t) - p.d * Math.sin((p.R - p.r) * t / p.r);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = `hsla(${p.hue}, ${p.sat}%, ${p.light}%, ${p.alpha})`;
ctx.lineWidth = 0.4 + r(c * 3) * 0.8;
ctx.stroke();
}
ctx.globalCompositeOperation = 'source-over';
// Subtle center glow
const grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, 200);
grd.addColorStop(0, 'rgba(180, 120, 255, 0.06)');
grd.addColorStop(1, 'rgba(180, 120, 255, 0)');
ctx.fillStyle = grd;
ctx.fillRect(0, 0, 500, 500);
Fifteen spirograph curves blended with screen mode. Where curves overlap, their colors add — creating bright nodes at intersection points and dark voids where nothing reaches. The deterministic random function means you can change the seed constant (42 in this example) to generate entirely different compositions while keeping the aesthetic consistent.
The mathematics of spirograph patterns
A few properties worth knowing:
- Number of petals: A hypotrochoid with parameters R and r produces R/gcd(R,r) petals (or cusps, or loops — depending on the d parameter). For R=200, r=75: gcd=25, so 200/25 = 8 petals.
- Closure: The curve closes after the inner circle completes R/gcd(R,r) full revolutions. The pen traces r/gcd(R,r) loops around the center.
- Special cases: When R/r is an integer, you get a hypocycloid with R/r cusps. R/r=3 → deltoid. R/r=4 → astroid. R/r=2 → straight line (the pen traces a diameter).
- Epitrochoid specials: R/r=1 → cardioid (the heart curve). R/r=2 → nephroid. These are the same curves that appear as caustics — light reflected inside a coffee cup.
- Fourier connection: Any periodic curve can be decomposed into a sum of circles rotating at different frequencies. A spirograph with one gear = one harmonic. Add more gears = add more harmonics. Enough gears can approximate any shape — this is exactly Fourier's theorem, made physical.
History
The math predates the toy by a century. Bruno Abakanowicz built a mechanical integraph using hypotrochoid linkages in 1879. The Spirograph toy was invented by Denys Fisher, a British engineer who was designing bomb detonators and noticed the patterns produced by meshing gears. He presented it at the 1965 Nuremberg Toy Fair. Hasbro bought the rights and sold millions.
But the curves themselves appear across human history — in Islamic geometric art, Celtic knotwork, Rhodian coin decorations from 500 BCE, and the rose windows of Gothic cathedrals. The underlying geometry is universal. The Spirograph just made it accessible to kids.
Going further
- Explore Lissajous curves for another family of parametric curves based on harmonic oscillation
- Learn about mathematical art for related topics: rose curves, superformula, strange attractors, and phyllotaxis
- Try geometric art for Islamic patterns, Penrose tilings, kaleidoscopes, and other geometric constructions
- Combine spirograph curves with Bézier curves — use spirograph points as control points for smooth, flowing generative paths
- Add Perlin noise to the gear parameters for organic, imperfect spirographs that breathe and shift
- Export to SVG for pen plotter output — spirographs are one of the most satisfying things to plot physically
- Try modulating the pen distance
das a function of time for roulette modulation — the resulting curves are called rose-trochoids and produce flower-like forms - On Lumitree, several micro-worlds use spirograph mathematics as their visual backbone — every gear ratio creates a different kind of beauty