Lissajous Curves: How to Create Mesmerizing Harmonic Patterns With Code
Lissajous curves — sometimes called Lissajous figures or Bowditch curves — are among the most beautiful patterns in mathematics. Discovered independently by Nathaniel Bowditch (1815) and Jules Antoine Lissajous (1857), these curves emerge when you combine two perpendicular oscillations. The result: mesmerizing harmonic patterns that range from simple ellipses to intricate, woven figures that seem almost alive.
If you've ever watched an oscilloscope trace or seen a harmonograph drawing, you've seen Lissajous curves in action. They appear naturally in physics (pendulums, sound waves, orbital mechanics), music visualization, and generative art. In this guide, we'll build 8 interactive examples from scratch using nothing but JavaScript and the Canvas API.
What is a Lissajous curve?
A Lissajous curve is defined by two parametric equations:
x(t) = A * sin(a * t + δ)
y(t) = B * sin(b * t)
Where:
- A, B — amplitudes (how far the curve stretches in x and y)
- a, b — frequencies (how fast each axis oscillates)
- δ (delta) — phase shift (offsets the x oscillation relative to y)
- t — the parameter, typically running from 0 to 2π
The magic lies in the frequency ratio a:b. When the ratio is rational (like 1:2, 3:4, or 5:7), the curve closes on itself after a finite number of loops. When it's irrational (like 1:√2), the curve never closes and fills a rectangular region over time.
1. Basic Lissajous figure
Let's start with the simplest implementation — drawing a single Lissajous curve by sampling the parametric equations:
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, A = 200, B = 200;
const a = 3, b = 2, delta = Math.PI / 2;
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 500, 500);
ctx.beginPath();
const steps = 1000;
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * 2 * Math.PI;
const x = cx + A * Math.sin(a * t + delta);
const y = cy + B * Math.sin(b * t);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.strokeStyle = '#00ffaa';
ctx.lineWidth = 2;
ctx.stroke();
With a=3, b=2, and a phase shift of π/2, you get a classic three-lobed figure. Change the ratio to see different patterns: 1:1 gives an ellipse, 1:2 gives a figure-eight (or parabola, depending on phase), and higher ratios create increasingly intricate patterns.
2. Frequency ratio explorer
The frequency ratio is everything. This example draws a grid of Lissajous figures for different a:b ratios, creating a beautiful reference chart:
const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 600;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 600, 600);
const maxFreq = 5;
const cellW = 600 / (maxFreq + 1);
const cellH = 600 / (maxFreq + 1);
// Labels
ctx.fillStyle = '#ffffff44';
ctx.font = '12px monospace';
ctx.textAlign = 'center';
for (let i = 1; i <= maxFreq; i++) {
ctx.fillText(i, (i + 0.5) * cellW, 14);
ctx.fillText(i, 14, (i + 0.5) * cellH + 4);
}
ctx.fillStyle = '#ffffff22';
ctx.fillText('a →', 300, 14);
ctx.save(); ctx.translate(14, 300);
ctx.rotate(-Math.PI/2); ctx.fillText('b →', 0, 0);
ctx.restore();
for (let ai = 1; ai <= maxFreq; ai++) {
for (let bi = 1; bi <= maxFreq; bi++) {
const ox = (ai + 0.5) * cellW;
const oy = (bi + 0.5) * cellH;
const r = cellW * 0.35;
const hue = ((ai * 60 + bi * 40) % 360);
ctx.beginPath();
for (let i = 0; i <= 500; i++) {
const t = (i / 500) * 2 * Math.PI;
const x = ox + r * Math.sin(ai * t + Math.PI / 2);
const y = oy + r * Math.sin(bi * t);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.strokeStyle = `hsl(${hue}, 80%, 60%)`;
ctx.lineWidth = 1.2;
ctx.stroke();
}
}
This grid reveals the deep structure: diagonal cells (1:1, 2:2, 3:3) are always ellipses. Cells where a and b share a common factor simplify to the reduced ratio. The most complex figures appear when a and b are coprime (share no common factors).
3. Animated phase shift
When you smoothly animate the phase shift δ, Lissajous curves come alive — they rotate, twist, and morph continuously. This is what you see on an oscilloscope when two frequencies are slightly detuned:
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, A = 180, B = 180;
const a = 3, b = 4;
const trail = [];
const maxTrail = 60;
function draw(time) {
ctx.fillStyle = 'rgba(10, 10, 26, 0.08)';
ctx.fillRect(0, 0, 500, 500);
const delta = time * 0.0005;
const points = [];
for (let i = 0; i <= 600; i++) {
const t = (i / 600) * 2 * Math.PI;
points.push({
x: cx + A * Math.sin(a * t + delta),
y: cy + B * Math.sin(b * t)
});
}
// Draw with gradient trail effect
for (let i = 1; i < points.length; i++) {
const progress = i / points.length;
const hue = (progress * 120 + time * 0.02) % 360;
ctx.beginPath();
ctx.moveTo(points[i-1].x, points[i-1].y);
ctx.lineTo(points[i].x, points[i].y);
ctx.strokeStyle = `hsl(${hue}, 85%, 55%)`;
ctx.lineWidth = 2;
ctx.stroke();
}
// Draw tracing dot
const dotT = (time * 0.001) % (2 * Math.PI);
const dx = cx + A * Math.sin(a * dotT + delta);
const dy = cy + B * Math.sin(b * dotT);
ctx.beginPath();
ctx.arc(dx, dy, 5, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff';
ctx.fill();
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
The slowly rotating phase creates an effect like a three-dimensional object spinning in space. In fact, many "3D rotation" visualizations are just animated Lissajous figures with shifting phase.
4. 3D Lissajous projection
Extend to three dimensions by adding a z-component. Project onto 2D with simple perspective:
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 freqX = 2, freqY = 3, freqZ = 5;
const phaseX = 0, phaseY = Math.PI / 4, phaseZ = Math.PI / 3;
const amp = 150;
function draw(time) {
ctx.fillStyle = 'rgba(10, 10, 26, 0.06)';
ctx.fillRect(0, 0, 500, 500);
const rotY = time * 0.0003;
const rotX = time * 0.0002;
const cosRY = Math.cos(rotY), sinRY = Math.sin(rotY);
const cosRX = Math.cos(rotX), sinRX = Math.sin(rotX);
const points = [];
const steps = 800;
for (let i = 0; i <= steps; i++) {
const t = (i / steps) * 2 * Math.PI;
let x = amp * Math.sin(freqX * t + phaseX);
let y = amp * Math.sin(freqY * t + phaseY);
let z = amp * Math.sin(freqZ * t + phaseZ);
// Rotate around Y axis
const x1 = x * cosRY - z * sinRY;
const z1 = x * sinRY + z * cosRY;
// Rotate around X axis
const y1 = y * cosRX - z1 * sinRX;
const z2 = y * sinRX + z1 * cosRX;
const perspective = 600 / (600 + z2);
points.push({
px: cx + x1 * perspective,
py: cy + y1 * perspective,
z: z2,
t: i / steps
});
}
// Draw back-to-front with depth shading
for (let i = 1; i < points.length; i++) {
const p = points[i];
const prev = points[i-1];
const depth = (p.z + amp) / (2 * amp);
const alpha = 0.3 + depth * 0.7;
const hue = (p.t * 360 + time * 0.01) % 360;
ctx.beginPath();
ctx.moveTo(prev.px, prev.py);
ctx.lineTo(p.px, p.py);
ctx.strokeStyle = `hsla(${hue}, 80%, 55%, ${alpha})`;
ctx.lineWidth = 1 + depth * 2;
ctx.stroke();
}
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
Three-dimensional Lissajous curves with frequency ratios like 2:3:5 create stunning, sculptural forms that rotate smoothly in space. These are sometimes called Lissajous knots — when the three frequencies are pairwise coprime, the curve forms an actual mathematical knot.
5. Harmonograph simulation
A harmonograph is a physical drawing machine with pendulums that produce damped Lissajous-like figures. The damping makes the pattern spiral inward over time, creating drawings of extraordinary delicacy:
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;
// Two lateral pendulums + one rotary pendulum
const p1 = { f: 2.01, phase: 0, amp: 120, decay: 0.0008 };
const p2 = { f: 3.00, phase: Math.PI/3, amp: 120, decay: 0.0010 };
const p3 = { f: 2.99, phase: Math.PI/5, amp: 80, decay: 0.0006 };
const totalSteps = 20000;
ctx.globalAlpha = 0.6;
let prevX, prevY;
for (let i = 0; i <= totalSteps; i++) {
const t = i * 0.02;
const d1 = Math.exp(-p1.decay * t);
const d2 = Math.exp(-p2.decay * t);
const d3 = Math.exp(-p3.decay * t);
const x = cx
+ p1.amp * d1 * Math.sin(p1.f * t + p1.phase)
+ p3.amp * d3 * Math.sin(p3.f * t + p3.phase);
const y = cy
+ p2.amp * d2 * Math.sin(p2.f * t + p2.phase);
if (i > 0) {
const progress = i / totalSteps;
const hue = progress * 280;
const alpha = 0.1 + (1 - progress) * 0.5;
ctx.beginPath();
ctx.moveTo(prevX, prevY);
ctx.lineTo(x, y);
ctx.strokeStyle = `hsla(${hue}, 70%, 60%, ${alpha})`;
ctx.lineWidth = 0.8 + (1 - progress) * 1.2;
ctx.stroke();
}
prevX = x; prevY = y;
}
ctx.globalAlpha = 1;
The slightly detuned frequencies (2.01 vs 2.00, 3.00 vs 2.99) cause the pattern to slowly precess — each loop is slightly offset from the last. The exponential damping makes outer loops bold and inner loops delicate, exactly like a real harmonograph. Tweak the frequencies by tiny amounts (try 2.001 vs 2.003) for radically different drawings.
6. Lissajous table
A Lissajous table animates dots on the edges and draws curves at the intersections. It's an elegant way to visualize how the horizontal and vertical oscillations combine:
const canvas = document.createElement('canvas');
canvas.width = 550; canvas.height = 550;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const grid = 4;
const margin = 70;
const cellSize = (550 - margin) / grid;
const r = cellSize * 0.4;
// Store paths for each cell
const paths = Array.from({length: grid}, () =>
Array.from({length: grid}, () => [])
);
function draw(time) {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 550, 550);
const t = time * 0.001;
// Draw column circles (top)
for (let col = 0; col < grid; col++) {
const freq = col + 1;
const ox = margin + col * cellSize + cellSize / 2;
const oy = margin / 2;
ctx.beginPath();
ctx.arc(ox, oy, 18, 0, Math.PI * 2);
ctx.strokeStyle = '#ffffff22';
ctx.lineWidth = 1;
ctx.stroke();
const dotX = ox + 18 * Math.cos(freq * t);
const dotY = oy + 18 * Math.sin(freq * t);
ctx.beginPath();
ctx.arc(dotX, dotY, 3, 0, Math.PI * 2);
ctx.fillStyle = `hsl(${col * 70}, 80%, 60%)`;
ctx.fill();
// Vertical guide line
ctx.beginPath();
ctx.moveTo(ox + 18 * Math.cos(freq * t), margin);
ctx.lineTo(ox + 18 * Math.cos(freq * t), 550);
ctx.strokeStyle = '#ffffff08';
ctx.stroke();
}
// Draw row circles (left)
for (let row = 0; row < grid; row++) {
const freq = row + 1;
const ox = margin / 2;
const oy = margin + row * cellSize + cellSize / 2;
ctx.beginPath();
ctx.arc(ox, oy, 18, 0, Math.PI * 2);
ctx.strokeStyle = '#ffffff22';
ctx.stroke();
const dotX = ox + 18 * Math.cos(freq * t);
const dotY = oy + 18 * Math.sin(freq * t);
ctx.beginPath();
ctx.arc(dotX, dotY, 3, 0, Math.PI * 2);
ctx.fillStyle = `hsl(${row * 70 + 180}, 80%, 60%)`;
ctx.fill();
}
// Draw Lissajous curves in each cell
for (let row = 0; row < grid; row++) {
for (let col = 0; col < grid; col++) {
const freqX = col + 1;
const freqY = row + 1;
const ox = margin + col * cellSize + cellSize / 2;
const oy = margin + row * cellSize + cellSize / 2;
const x = ox + r * Math.cos(freqX * t);
const y = oy + r * Math.sin(freqY * t);
paths[row][col].push({x, y});
if (paths[row][col].length > 600) paths[row][col].shift();
// Draw path
const pts = paths[row][col];
if (pts.length > 1) {
for (let i = 1; i < pts.length; i++) {
const alpha = i / pts.length;
ctx.beginPath();
ctx.moveTo(pts[i-1].x, pts[i-1].y);
ctx.lineTo(pts[i].x, pts[i].y);
const hue = ((col + row) * 50) % 360;
ctx.strokeStyle = `hsla(${hue}, 70%, 55%, ${alpha * 0.8})`;
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
// Draw current point
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff';
ctx.fill();
}
}
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
This visualization was popularized by The Coding Train and is one of the most intuitive ways to understand how frequency ratios shape the curves. Each cell shows the curve formed by combining the column's x-frequency with the row's y-frequency.
7. Audio-reactive Lissajous
Lissajous curves have a deep connection to sound — they were originally used to visualize audio frequencies on oscilloscopes. Here we use the Web Audio API to drive the curve's parameters from a microphone or oscillator:
const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// Create two oscillators at different frequencies
const osc1 = audioCtx.createOscillator();
const osc2 = audioCtx.createOscillator();
const gain1 = audioCtx.createGain();
const gain2 = audioCtx.createGain();
const analyser1 = audioCtx.createAnalyser();
const analyser2 = audioCtx.createAnalyser();
osc1.frequency.value = 220; // A3
osc2.frequency.value = 330; // E4 (3:2 ratio = perfect fifth)
gain1.gain.value = 0.15;
gain2.gain.value = 0.15;
analyser1.fftSize = 256;
analyser2.fftSize = 256;
osc1.connect(gain1).connect(analyser1).connect(audioCtx.destination);
osc2.connect(gain2).connect(analyser2).connect(audioCtx.destination);
osc1.start(); osc2.start();
const buf1 = new Float32Array(analyser1.fftSize);
const buf2 = new Float32Array(analyser2.fftSize);
const trail = [];
const maxTrail = 8;
function draw() {
ctx.fillStyle = 'rgba(10, 10, 26, 0.15)';
ctx.fillRect(0, 0, 500, 500);
analyser1.getFloatTimeDomainData(buf1);
analyser2.getFloatTimeDomainData(buf2);
// Map waveform samples to x,y coordinates
const points = [];
const len = Math.min(buf1.length, buf2.length);
for (let i = 0; i < len; i++) {
points.push({
x: 250 + buf1[i] * 200,
y: 250 + buf2[i] * 200
});
}
// Draw the Lissajous
if (points.length > 1) {
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.strokeStyle = '#00ffaa';
ctx.lineWidth = 2;
ctx.shadowColor = '#00ffaa';
ctx.shadowBlur = 10;
ctx.stroke();
ctx.shadowBlur = 0;
}
// Slowly modulate osc2 frequency for variation
const t = audioCtx.currentTime;
osc2.frequency.value = 330 + Math.sin(t * 0.1) * 5;
requestAnimationFrame(draw);
}
// Click to start (browser autoplay policy)
canvas.addEventListener('click', () => {
audioCtx.resume();
}, { once: true });
canvas.style.cursor = 'pointer';
draw();
The two oscillators at 220Hz and 330Hz form a 2:3 ratio (a musical perfect fifth). The slight frequency modulation on the second oscillator causes the figure to slowly rotate and breathe. On a real oscilloscope, you'd feed a signal into the X channel and another into Y — this is exactly that, in software. Click the canvas to start audio.
8. Generative Lissajous art
Finally, let's combine everything into a generative artwork — multiple layered Lissajous curves with varying parameters, damping, and color, creating a rich, organic composition:
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 randomCurve(seed) {
const r = (s) => {
s = Math.sin(s * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
};
return {
freqA: Math.floor(r(seed) * 7) + 1,
freqB: Math.floor(r(seed + 1) * 7) + 1,
phase: r(seed + 2) * Math.PI * 2,
ampA: 60 + r(seed + 3) * 140,
ampB: 60 + r(seed + 4) * 140,
decay: 0.0002 + r(seed + 5) * 0.001,
hue: r(seed + 6) * 360,
steps: 8000 + Math.floor(r(seed + 7) * 12000)
};
}
const numCurves = 12;
ctx.globalCompositeOperation = 'screen';
for (let c = 0; c < numCurves; c++) {
const p = randomCurve(c * 8.7 + 42);
let prevX, prevY;
for (let i = 0; i <= p.steps; i++) {
const t = i * 0.015;
const d = Math.exp(-p.decay * t);
const x = cx + p.ampA * d * Math.sin(p.freqA * t + p.phase);
const y = cy + p.ampB * d * Math.sin(p.freqB * t);
if (i > 0) {
const progress = i / p.steps;
const alpha = (1 - progress) * 0.3;
ctx.beginPath();
ctx.moveTo(prevX, prevY);
ctx.lineTo(x, y);
ctx.strokeStyle = `hsla(${p.hue + progress * 60}, 65%, 55%, ${alpha})`;
ctx.lineWidth = 0.5 + (1 - progress) * 1;
ctx.stroke();
}
prevX = x; prevY = y;
}
}
ctx.globalCompositeOperation = 'source-over';
// Add subtle center glow
const grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, 250);
grd.addColorStop(0, 'rgba(100, 200, 255, 0.05)');
grd.addColorStop(1, 'rgba(100, 200, 255, 0)');
ctx.fillStyle = grd;
ctx.fillRect(0, 0, 500, 500);
Twelve layered harmonograph-style curves with the screen blend mode create a luminous, ethereal composition. Each curve has different frequency ratios, amplitudes, phase offsets, and decay rates, generated from a deterministic seed function. The result is different every time you change the seed, but always harmonious — because the underlying math guarantees it.
The physics of Lissajous curves
Lissajous curves aren't just mathematical curiosities — they're windows into fundamental physics:
- Pendulums: A pendulum swinging freely in two dimensions traces a Lissajous curve. A Foucault pendulum at the North Pole traces a perfect circle (1:1 ratio) that appears to rotate due to Earth's rotation.
- Sound waves: When you feed two audio signals into an oscilloscope's X and Y inputs, the trace is a Lissajous figure. A stable figure means the frequencies are in a simple ratio — a musical interval. A rotating figure means they're slightly detuned.
- Orbital mechanics: Some Lissajous orbits exist around Lagrange points in space. The James Webb Space Telescope follows a Lissajous orbit around the Sun-Earth L2 point.
- Quantum mechanics: The probability densities of quantum harmonic oscillators in 2D form patterns identical to Lissajous figures.
- Music: The frequency ratios that produce the simplest Lissajous curves (1:2, 2:3, 3:4) correspond to the most consonant musical intervals (octave, perfect fifth, perfect fourth). Dissonant intervals produce complex, dense figures.
Musical intervals as Lissajous curves
There's a deep connection between harmony in music and simplicity in Lissajous figures:
- Unison (1:1) — ellipse or line
- Octave (1:2) — figure eight
- Perfect fifth (2:3) — three loops
- Perfect fourth (3:4) — four loops
- Major third (4:5) — five loops
- Minor third (5:6) — six loops
The more complex the ratio, the more loops in the figure, and the more dissonant the musical interval sounds. This isn't a coincidence — both phenomena arise from the same mathematical structure of interacting periodic functions.
Going further
- Explore mathematical art for related parametric curves including rose curves, superformula, and strange attractors
- Learn about Bézier curves for another fundamental curve type used in graphics, fonts, and animation
- Try kinetic art for pendulum-based motion and mechanical drawing machines
- Combine Lissajous curves with audio visualization to create oscilloscope-style sound art
- Add Perlin noise to the frequency parameters for organic, never-repeating variations
- Experiment with Lissajous knots in 3D — when the three frequencies are pairwise coprime, the resulting space curve forms a trefoil knot, torus knot, or other mathematical knot
- Build a physical harmonograph simulator with multiple damped pendulums and export the results as SVG for pen plotter output
- On Lumitree, several micro-worlds use Lissajous patterns as the backbone for their organic, oscillating visuals — every harmonic ratio creates a new kind of beauty