All articles
22 min read

Pendulum Wave: How to Create Mesmerizing Phase Animations With Code

pendulum wavephysics simulationcreative codingJavaScriptcanvasanimationharmonic motiongenerative art

A pendulum wave is one of the most hypnotic demonstrations in physics. A row of pendulums, each slightly longer than the last, swing together in perfect unison — then slowly drift apart, creating traveling waves, snaking curves, and apparent chaos, before magically returning to synchrony. The effect is mesmerizing, and the math behind it is beautifully simple.

In this article, we build 8 pendulum wave visualizations from scratch using JavaScript and Canvas. Every example runs live in your browser with no dependencies. We start with the classic linear pendulum wave, then explore circular arrays, rainbow phase gradients, Lissajous combinations, 3D perspective, interactive frequency tuning, coupled pendulums with energy transfer, and finish with a generative pendulum art composition.

The Physics of Pendulum Waves

A simple pendulum's period depends only on its length: T = 2π√(L/g), where L is the length and g is gravitational acceleration (9.81 m/s²). A pendulum wave exploits this: by choosing lengths so that each pendulum completes a different integer number of oscillations in the same overall cycle time, you get predictable phase relationships that produce stunning visual patterns.

If we want pendulum i to complete N+i oscillations in cycle time Tcycle, its period must be Ti = Tcycle / (N+i), and its length Li = g · (Ti / 2π)².

1. Basic Pendulum Wave

The classic setup: 15 pendulums in a row, each tuned so that in 60 seconds they complete 51, 52, 53... 65 oscillations. Watch them start in phase, create traveling waves, appear random, then snap back together.

const canvas = document.createElement('canvas');
canvas.width = 700; canvas.height = 500;
canvas.style.background = '#0a0e17';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const N = 15;
const cycleTime = 32;
const baseOsc = 51;
const pivotY = 60;
const rodLength = 300;
const bobRadius = 10;
const maxAngle = Math.PI / 6;

const pendulums = Array.from({ length: N }, (_, i) => {
  const oscillations = baseOsc + i;
  const frequency = oscillations / cycleTime;
  return { frequency, x: 80 + i * ((canvas.width - 160) / (N - 1)) };
});

let startTime = performance.now();

function draw(now) {
  const t = (now - startTime) / 1000;
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // pivot bar
  ctx.strokeStyle = '#1a3a2a';
  ctx.lineWidth = 4;
  ctx.beginPath();
  ctx.moveTo(40, pivotY);
  ctx.lineTo(canvas.width - 40, pivotY);
  ctx.stroke();

  for (const p of pendulums) {
    const angle = maxAngle * Math.sin(2 * Math.PI * p.frequency * t);
    const bobX = p.x + rodLength * Math.sin(angle);
    const bobY = pivotY + rodLength * Math.cos(angle);

    // rod
    ctx.strokeStyle = 'rgba(110,230,180,0.3)';
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.moveTo(p.x, pivotY);
    ctx.lineTo(bobX, bobY);
    ctx.stroke();

    // bob
    const phase = (Math.sin(2 * Math.PI * p.frequency * t) + 1) / 2;
    const hue = 140 + phase * 60;
    ctx.fillStyle = `hsl(${hue}, 70%, 55%)`;
    ctx.beginPath();
    ctx.arc(bobX, bobY, bobRadius, 0, Math.PI * 2);
    ctx.fill();

    // pivot
    ctx.fillStyle = '#2a5a3a';
    ctx.beginPath();
    ctx.arc(p.x, pivotY, 3, 0, Math.PI * 2);
    ctx.fill();
  }

  // time + cycle info
  ctx.fillStyle = 'rgba(110,230,180,0.4)';
  ctx.font = '12px monospace';
  ctx.fillText(`t = ${t.toFixed(1)}s  |  cycle = ${cycleTime}s  |  click to reset`, 20, canvas.height - 15);

  requestAnimationFrame(draw);
}

canvas.addEventListener('click', () => { startTime = performance.now(); });
requestAnimationFrame(draw);

2. Circular Pendulum Array

Instead of a line, arrange pendulums in a circle. Each pendulum swings radially outward from the center. The phase pattern creates mesmerizing rotational waves — spirals, pulsing rings, and flower-like blooms.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 600;
canvas.style.background = '#0a0e17';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const N = 24;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const ringRadius = 80;
const armLength = 160;
const cycleTime = 30;
const baseOsc = 40;
const maxAngle = 0.35;

const pendulums = Array.from({ length: N }, (_, i) => {
  const theta = (i / N) * Math.PI * 2;
  const oscillations = baseOsc + i;
  return { theta, frequency: oscillations / cycleTime };
});

let startTime = performance.now();

function draw(now) {
  const t = (now - startTime) / 1000;
  ctx.fillStyle = 'rgba(10, 14, 23, 0.15)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // center ring
  ctx.strokeStyle = 'rgba(110,230,180,0.15)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.arc(cx, cy, ringRadius, 0, Math.PI * 2);
  ctx.stroke();

  for (const p of pendulums) {
    const pivotX = cx + ringRadius * Math.cos(p.theta);
    const pivotY = cy + ringRadius * Math.sin(p.theta);

    const swing = maxAngle * Math.sin(2 * Math.PI * p.frequency * t);
    const armAngle = p.theta + swing;
    const bobX = pivotX + armLength * Math.cos(armAngle);
    const bobY = pivotY + armLength * Math.sin(armAngle);

    // arm
    ctx.strokeStyle = 'rgba(110,230,180,0.2)';
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(pivotX, pivotY);
    ctx.lineTo(bobX, bobY);
    ctx.stroke();

    // bob
    const phase = (Math.sin(2 * Math.PI * p.frequency * t) + 1) / 2;
    const hue = (p.theta / (Math.PI * 2)) * 360;
    ctx.fillStyle = `hsla(${hue}, 70%, ${40 + phase * 30}%, 0.9)`;
    ctx.beginPath();
    ctx.arc(bobX, bobY, 6, 0, Math.PI * 2);
    ctx.fill();
  }

  requestAnimationFrame(draw);
}

canvas.addEventListener('click', () => { startTime = performance.now(); });
requestAnimationFrame(draw);

3. Rainbow Phase Gradient

Map each pendulum's phase to a full rainbow spectrum, and draw trails. The result is a cascading ribbon of color that reveals the wave pattern as a continuously flowing gradient. Trails fade slowly to create a luminous afterglow.

const canvas = document.createElement('canvas');
canvas.width = 700; canvas.height = 500;
canvas.style.background = '#0a0e17';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const N = 30;
const cycleTime = 40;
const baseOsc = 30;
const amplitude = 160;
const centerY = canvas.height / 2;

const pendulums = Array.from({ length: N }, (_, i) => ({
  x: 30 + i * ((canvas.width - 60) / (N - 1)),
  frequency: (baseOsc + i) / cycleTime,
  trail: [],
}));

let startTime = performance.now();

function draw(now) {
  const t = (now - startTime) / 1000;

  ctx.fillStyle = 'rgba(10, 14, 23, 0.08)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  for (let i = 0; i < N; i++) {
    const p = pendulums[i];
    const y = centerY + amplitude * Math.sin(2 * Math.PI * p.frequency * t);
    const hue = (i / N) * 360;

    // trail
    p.trail.push(y);
    if (p.trail.length > 50) p.trail.shift();

    for (let j = 0; j < p.trail.length; j++) {
      const alpha = j / p.trail.length;
      ctx.fillStyle = `hsla(${hue}, 80%, 55%, ${alpha * 0.4})`;
      ctx.beginPath();
      ctx.arc(p.x, p.trail[j], 3 + alpha * 4, 0, Math.PI * 2);
      ctx.fill();
    }

    // main bob
    ctx.fillStyle = `hsl(${hue}, 85%, 60%)`;
    ctx.beginPath();
    ctx.arc(p.x, y, 8, 0, Math.PI * 2);
    ctx.fill();

    // glow
    const grad = ctx.createRadialGradient(p.x, y, 0, p.x, y, 20);
    grad.addColorStop(0, `hsla(${hue}, 85%, 60%, 0.3)`);
    grad.addColorStop(1, 'transparent');
    ctx.fillStyle = grad;
    ctx.beginPath();
    ctx.arc(p.x, y, 20, 0, Math.PI * 2);
    ctx.fill();
  }

  // connecting curve
  ctx.strokeStyle = 'rgba(255,255,255,0.15)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  for (let i = 0; i < N; i++) {
    const p = pendulums[i];
    const y = centerY + amplitude * Math.sin(2 * Math.PI * p.frequency * t);
    if (i === 0) ctx.moveTo(p.x, y);
    else ctx.lineTo(p.x, y);
  }
  ctx.stroke();

  requestAnimationFrame(draw);
}

canvas.addEventListener('click', () => {
  startTime = performance.now();
  pendulums.forEach(p => p.trail = []);
});
requestAnimationFrame(draw);

4. Lissajous Pendulums

Combine two pendulum waves at right angles. Each pendulum swings in both X and Y with different frequency ratios, tracing Lissajous curves. The array of overlapping figures creates a dancing constellation of curves.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 600;
canvas.style.background = '#0a0e17';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const grid = 4;
const N = grid * grid;
const cellW = canvas.width / grid;
const cellH = canvas.height / grid;
const amplitude = cellW * 0.35;
const cycleTime = 24;
const baseOsc = 20;

const pendulums = [];
for (let row = 0; row < grid; row++) {
  for (let col = 0; col < grid; col++) {
    const cx = (col + 0.5) * cellW;
    const cy = (row + 0.5) * cellH;
    const freqX = (baseOsc + col) / cycleTime;
    const freqY = (baseOsc + row) / cycleTime;
    pendulums.push({ cx, cy, freqX, freqY, trail: [] });
  }
}

let startTime = performance.now();

function draw(now) {
  const t = (now - startTime) / 1000;
  ctx.fillStyle = 'rgba(10, 14, 23, 0.05)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // grid lines
  ctx.strokeStyle = 'rgba(110,230,180,0.06)';
  ctx.lineWidth = 1;
  for (let i = 1; i < grid; i++) {
    ctx.beginPath();
    ctx.moveTo(i * cellW, 0); ctx.lineTo(i * cellW, canvas.height);
    ctx.moveTo(0, i * cellH); ctx.lineTo(canvas.width, i * cellH);
    ctx.stroke();
  }

  for (const p of pendulums) {
    const x = p.cx + amplitude * Math.sin(2 * Math.PI * p.freqX * t);
    const y = p.cy + amplitude * Math.sin(2 * Math.PI * p.freqY * t);

    p.trail.push({ x, y });
    if (p.trail.length > 200) p.trail.shift();

    // trail
    ctx.strokeStyle = 'rgba(110,230,180,0.3)';
    ctx.lineWidth = 1;
    ctx.beginPath();
    for (let i = 0; i < p.trail.length; i++) {
      if (i === 0) ctx.moveTo(p.trail[i].x, p.trail[i].y);
      else ctx.lineTo(p.trail[i].x, p.trail[i].y);
    }
    ctx.stroke();

    // bob
    ctx.fillStyle = '#6ee6b4';
    ctx.beginPath();
    ctx.arc(x, y, 3, 0, Math.PI * 2);
    ctx.fill();
  }

  requestAnimationFrame(draw);
}

canvas.addEventListener('click', () => {
  startTime = performance.now();
  pendulums.forEach(p => p.trail = []);
});
requestAnimationFrame(draw);

5. 3D Perspective Pendulum Wave

Add depth: arrange pendulums in a line receding into the screen. A simple perspective projection makes closer pendulums larger and farther ones smaller, creating a striking 3D tunnel effect as the wave propagates.

const canvas = document.createElement('canvas');
canvas.width = 700; canvas.height = 500;
canvas.style.background = '#0a0e17';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const N = 20;
const cycleTime = 36;
const baseOsc = 40;
const maxAngle = Math.PI / 5;
const focalLength = 400;
const depthSpacing = 40;

function project(x3d, y3d, z3d) {
  const scale = focalLength / (focalLength + z3d);
  return {
    x: canvas.width / 2 + x3d * scale,
    y: 160 + y3d * scale,
    scale,
  };
}

const pendulums = Array.from({ length: N }, (_, i) => ({
  z: i * depthSpacing,
  frequency: (baseOsc + i) / cycleTime,
}));

let startTime = performance.now();

function draw(now) {
  const t = (now - startTime) / 1000;
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // draw back to front
  for (let i = N - 1; i >= 0; i--) {
    const p = pendulums[i];
    const angle = maxAngle * Math.sin(2 * Math.PI * p.frequency * t);
    const armLen = 250;

    const pivotProj = project(0, 0, p.z);
    const bobX3d = armLen * Math.sin(angle);
    const bobY3d = armLen * Math.cos(angle);
    const bobProj = project(bobX3d, bobY3d, p.z);

    const alpha = 0.3 + 0.7 * (1 - i / N);

    // rod
    ctx.strokeStyle = `rgba(110,230,180,${alpha * 0.4})`;
    ctx.lineWidth = 2 * pivotProj.scale;
    ctx.beginPath();
    ctx.moveTo(pivotProj.x, pivotProj.y);
    ctx.lineTo(bobProj.x, bobProj.y);
    ctx.stroke();

    // bob
    const hue = 140 + (i / N) * 80;
    const radius = 10 * bobProj.scale;
    ctx.fillStyle = `hsla(${hue}, 70%, 55%, ${alpha})`;
    ctx.beginPath();
    ctx.arc(bobProj.x, bobProj.y, radius, 0, Math.PI * 2);
    ctx.fill();

    // glow
    if (bobProj.scale > 0.5) {
      const grad = ctx.createRadialGradient(bobProj.x, bobProj.y, 0, bobProj.x, bobProj.y, radius * 3);
      grad.addColorStop(0, `hsla(${hue}, 70%, 55%, ${alpha * 0.3})`);
      grad.addColorStop(1, 'transparent');
      ctx.fillStyle = grad;
      ctx.beginPath();
      ctx.arc(bobProj.x, bobProj.y, radius * 3, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  ctx.fillStyle = 'rgba(110,230,180,0.3)';
  ctx.font = '12px monospace';
  ctx.fillText('3D perspective pendulum wave — click to reset', 20, canvas.height - 15);

  requestAnimationFrame(draw);
}

canvas.addEventListener('click', () => { startTime = performance.now(); });
requestAnimationFrame(draw);

6. Interactive Frequency Tuner

Drag the mouse left/right to change the base frequency, and up/down to change the frequency spread between pendulums. This lets you explore the parameter space in real time — find settings that produce tight traveling waves, wide chaos, or rapid resynchronization.

const canvas = document.createElement('canvas');
canvas.width = 700; canvas.height = 500;
canvas.style.background = '#0a0e17';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const N = 20;
const centerY = canvas.height / 2;
const amplitude = 180;

let baseFreq = 1.5;
let freqSpread = 0.08;
let mouseDown = false;

canvas.addEventListener('mousedown', () => mouseDown = true);
canvas.addEventListener('mouseup', () => mouseDown = false);
canvas.addEventListener('mousemove', (e) => {
  if (!mouseDown) return;
  const rect = canvas.getBoundingClientRect();
  baseFreq = 0.5 + (e.clientX - rect.left) / rect.width * 3;
  freqSpread = 0.01 + (e.clientY - rect.top) / rect.height * 0.2;
});

let startTime = performance.now();

function draw(now) {
  const t = (now - startTime) / 1000;
  ctx.fillStyle = 'rgba(10, 14, 23, 0.12)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  const positions = [];
  for (let i = 0; i < N; i++) {
    const x = 40 + i * ((canvas.width - 80) / (N - 1));
    const freq = baseFreq + i * freqSpread;
    const y = centerY + amplitude * Math.sin(2 * Math.PI * freq * t);
    positions.push({ x, y, freq });

    const hue = 140 + (i / N) * 80;
    ctx.fillStyle = `hsl(${hue}, 70%, 55%)`;
    ctx.beginPath();
    ctx.arc(x, y, 8, 0, Math.PI * 2);
    ctx.fill();
  }

  // connecting curve
  ctx.strokeStyle = 'rgba(110,230,180,0.4)';
  ctx.lineWidth = 2;
  ctx.beginPath();
  for (let i = 0; i < positions.length; i++) {
    if (i === 0) ctx.moveTo(positions[i].x, positions[i].y);
    else ctx.lineTo(positions[i].x, positions[i].y);
  }
  ctx.stroke();

  // info
  ctx.fillStyle = 'rgba(110,230,180,0.5)';
  ctx.font = '13px monospace';
  ctx.fillText(`base freq: ${baseFreq.toFixed(2)} Hz  |  spread: ${freqSpread.toFixed(3)}  |  drag to tune`, 20, 25);
  ctx.fillText(`freq range: ${baseFreq.toFixed(2)} – ${(baseFreq + (N-1) * freqSpread).toFixed(2)} Hz`, 20, 45);

  requestAnimationFrame(draw);
}

canvas.addEventListener('click', () => { startTime = performance.now(); });
requestAnimationFrame(draw);

7. Coupled Pendulums With Energy Transfer

Connect adjacent pendulums with springs, so energy transfers between them. Unlike independent pendulum waves, coupled pendulums exhibit beat frequencies — energy sloshes back and forth between neighbors, creating a different kind of visual rhythm.

const canvas = document.createElement('canvas');
canvas.width = 700; canvas.height = 500;
canvas.style.background = '#0a0e17';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const N = 12;
const pivotY = 50;
const armLength = 300;
const bobRadius = 10;
const coupling = 0.3;
const damping = 0.9995;
const dt = 0.02;

const pendulums = Array.from({ length: N }, (_, i) => ({
  x: 60 + i * ((canvas.width - 120) / (N - 1)),
  angle: i === 0 ? 0.5 : 0,
  velocity: 0,
  naturalFreq: 3 + i * 0.15,
}));

function step() {
  for (let i = 0; i < N; i++) {
    const p = pendulums[i];
    let acc = -p.naturalFreq * p.naturalFreq * p.angle;

    // coupling to neighbors
    if (i > 0) acc += coupling * (pendulums[i - 1].angle - p.angle);
    if (i < N - 1) acc += coupling * (pendulums[i + 1].angle - p.angle);

    p.velocity += acc * dt;
    p.velocity *= damping;
    p.angle += p.velocity * dt;
  }
}

function draw() {
  for (let s = 0; s < 4; s++) step();

  ctx.fillStyle = 'rgba(10, 14, 23, 0.15)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // pivot bar
  ctx.strokeStyle = '#1a3a2a';
  ctx.lineWidth = 3;
  ctx.beginPath();
  ctx.moveTo(30, pivotY);
  ctx.lineTo(canvas.width - 30, pivotY);
  ctx.stroke();

  // coupling springs
  for (let i = 0; i < N - 1; i++) {
    const p1 = pendulums[i];
    const p2 = pendulums[i + 1];
    const midY = pivotY + armLength * 0.3;
    const x1 = p1.x + armLength * 0.3 * Math.sin(p1.angle);
    const x2 = p2.x + armLength * 0.3 * Math.sin(p2.angle);

    ctx.strokeStyle = 'rgba(230,180,110,0.3)';
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(x1, midY);
    // zigzag spring
    const segs = 8;
    const dx = (x2 - x1) / segs;
    for (let s = 1; s < segs; s++) {
      const sx = x1 + s * dx;
      const sy = midY + (s % 2 === 0 ? -6 : 6);
      ctx.lineTo(sx, sy);
    }
    ctx.lineTo(x2, midY);
    ctx.stroke();
  }

  for (let i = 0; i < N; i++) {
    const p = pendulums[i];
    const bobX = p.x + armLength * Math.sin(p.angle);
    const bobY = pivotY + armLength * Math.cos(p.angle);

    // rod
    ctx.strokeStyle = 'rgba(110,230,180,0.3)';
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.moveTo(p.x, pivotY);
    ctx.lineTo(bobX, bobY);
    ctx.stroke();

    // energy = 0.5 * v^2 (kinetic)
    const energy = Math.min(1, Math.abs(p.velocity) * 2);
    const hue = 140 - energy * 100;
    ctx.fillStyle = `hsl(${hue}, 70%, ${45 + energy * 25}%)`;
    ctx.beginPath();
    ctx.arc(bobX, bobY, bobRadius, 0, Math.PI * 2);
    ctx.fill();

    // pivot
    ctx.fillStyle = '#2a5a3a';
    ctx.beginPath();
    ctx.arc(p.x, pivotY, 3, 0, Math.PI * 2);
    ctx.fill();
  }

  ctx.fillStyle = 'rgba(110,230,180,0.4)';
  ctx.font = '12px monospace';
  ctx.fillText('coupled pendulums — energy transfers through springs — click to kick first pendulum', 20, canvas.height - 15);

  requestAnimationFrame(draw);
}

canvas.addEventListener('click', () => {
  pendulums.forEach(p => { p.angle = 0; p.velocity = 0; });
  pendulums[0].angle = 0.5;
});
requestAnimationFrame(draw);

8. Generative Pendulum Art

Combine multiple pendulum waves at different scales, map positions to color and size, and let trails accumulate into a generative artwork. Each click seeds a new composition by randomizing wave parameters. The result is a unique abstract painting drawn by physics.

const canvas = document.createElement('canvas');
canvas.width = 700; canvas.height = 700;
canvas.style.background = '#0a0e17';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

let waves = [];
let startTime;

function randomize() {
  ctx.fillStyle = '#0a0e17';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  startTime = performance.now();

  const numWaves = 3 + Math.floor(Math.random() * 3);
  waves = Array.from({ length: numWaves }, () => {
    const N = 8 + Math.floor(Math.random() * 20);
    const baseFreq = 0.5 + Math.random() * 2;
    const spread = 0.02 + Math.random() * 0.15;
    const hueBase = Math.random() * 360;
    const amplitudeX = 100 + Math.random() * 200;
    const amplitudeY = 100 + Math.random() * 200;
    const offsetX = canvas.width / 2 + (Math.random() - 0.5) * 100;
    const offsetY = canvas.height / 2 + (Math.random() - 0.5) * 100;
    const phaseOffset = Math.random() * Math.PI * 2;

    return {
      pendulums: Array.from({ length: N }, (_, i) => ({
        freqX: baseFreq + i * spread,
        freqY: baseFreq + i * spread * 1.3,
      })),
      hueBase, amplitudeX, amplitudeY, offsetX, offsetY, phaseOffset,
    };
  });
}

randomize();

function draw(now) {
  const t = (now - startTime) / 1000;

  if (t > 60) { randomize(); requestAnimationFrame(draw); return; }

  ctx.globalCompositeOperation = 'screen';

  for (const wave of waves) {
    for (let i = 0; i < wave.pendulums.length; i++) {
      const p = wave.pendulums[i];
      const x = wave.offsetX + wave.amplitudeX * Math.sin(2 * Math.PI * p.freqX * t + wave.phaseOffset);
      const y = wave.offsetY + wave.amplitudeY * Math.sin(2 * Math.PI * p.freqY * t);

      const hue = (wave.hueBase + i * 10 + t * 5) % 360;
      const size = 1.5 + Math.sin(t * 2 + i) * 1;

      ctx.fillStyle = `hsla(${hue}, 70%, 50%, 0.06)`;
      ctx.beginPath();
      ctx.arc(x, y, size, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  ctx.globalCompositeOperation = 'source-over';

  // info
  ctx.fillStyle = 'rgba(110,230,180,0.3)';
  ctx.font = '11px monospace';
  ctx.fillText(`${waves.length} wave layers | ${waves.reduce((s, w) => s + w.pendulums.length, 0)} pendulums | click for new composition`, 15, canvas.height - 12);

  requestAnimationFrame(draw);
}

canvas.addEventListener('click', randomize);
requestAnimationFrame(draw);

The Mathematics of Re-synchronization

The most magical moment in a pendulum wave is when all pendulums return to alignment. This happens because we chose integer frequency ratios. If pendulum i has frequency fi = (N+i)/Tcycle, then after time Tcycle, each pendulum has completed exactly N+i full oscillations — and since oscillation is periodic, they all return to their starting position simultaneously.

During the cycle, you see predictable stages: unison → traveling wave → standing wave → figure-eight patterns → apparent chaos → standing wave → traveling wave (reversed) → unison. These stages correspond to specific phase relationships between adjacent pendulums, and they repeat perfectly every cycle.

Building Physical Pendulum Waves

If you want to build a real pendulum wave, the key challenge is precision. A 1% error in pendulum length causes a 0.5% error in period, which compounds over multiple cycles. Tips:

  • Use the formula Li = g · (Tcycle / (2π(N+i)))² to calculate exact lengths
  • Fine-tune by timing — measure 10 oscillations with a stopwatch and adjust length
  • Minimize air resistance — use dense, compact bobs (steel ball bearings work well)
  • Keep amplitudes small — the simple pendulum formula assumes small angles (<15°)
  • Use rigid, lightweight rods instead of strings to prevent twisting

Performance Tips

  • Pendulum waves are cheap to simulate — each bob is just a sine function, no differential equations needed (unless you add coupling or nonlinear effects)
  • Use requestAnimationFrame time directly rather than accumulating dt to avoid drift
  • For trails, use a semi-transparent rectangle overlay instead of storing point arrays for smoother fade effects
  • Coupled pendulums need sub-stepping (multiple physics steps per frame) for stability — 4 sub-steps at dt=0.02 is a good starting point

The pendulum wave is proof that simple rules create extraordinary beauty. Each bob follows the same equation — angle = A·sin(2πft) — but a small frequency difference between neighbors produces an infinite variety of visual patterns. It's the same principle that drives chaos in double pendulums, elegance in Lissajous curves, and complexity in wave simulations. Explore more creative coding tutorials on the Lumitree blog, or visit the tree to discover generative micro-worlds grown from visitor seeds.

Related articles

Double Pendulum: How to Create Mesmerizing Chaos Art With Code
Learn to simulate double pendulums with JavaScript and Canvas. 8 interactive examples: basic simulation, trail painting, energy visualization, phase space portraits, butterfly effect comparison, pendulum array, damped pendulum with springs, and generative chaos art.
Kinetic Art: How to Create Moving Sculptures and Motion Art With Code
Learn to create kinetic art with JavaScript and Canvas. 8 interactive examples: pendulum waves, Calder-style mobiles, wind sculptures, harmonographs, kinetic typography, magnetic fields, mechanical linkages, and wave machines.
Gravity Simulation: How to Create Realistic Physics Art With Code
Learn how to build gravity simulations with JavaScript and HTML Canvas. 8 interactive examples: orbital mechanics, n-body systems, galaxy formation, particle gravity wells, spring physics, and more.
Lissajous Curves: How to Create Mesmerizing Harmonic Patterns With Code
Learn to create and animate Lissajous curves with JavaScript and Canvas. 8 interactive examples: basic figures, frequency ratios, phase animation, 3D projections, harmonograph simulation, Lissajous knots, audio-reactive curves, and generative Lissajous art.
Wave Simulation: How to Create Mesmerizing Wave Effects With Code
Learn to build stunning wave simulations from scratch with JavaScript and Canvas. 8 interactive examples: 1D string wave, 2D ripple tank, interference patterns, Doppler effect, diffraction through slits, standing waves, shallow water equations, and generative wave art.