All articles
16 min read

Color Theory: How to Create Beautiful Color Palettes With Code

color theorycreative codinggenerative artcolor paletteJavaScript

Color is the most immediately powerful tool in visual art. A single hue shift can transform a composition from somber to joyful, from aggressive to serene. Yet most programmers treat color as an afterthought — picking hex codes from a design tool, maybe randomizing RGB values. Understanding color theory transforms your generative art from technically impressive to emotionally resonant.

The good news: color theory is deeply mathematical. Color wheels are circles. Harmonies are geometric relationships. Palettes are algorithms. Everything that makes a color combination "feel right" can be expressed as code. And once you understand the math, you can generate infinite palettes that are guaranteed to work — not because you memorized rules, but because you programmed the relationships.

This guide covers eight working color systems you can build in your browser. Every example uses vanilla JavaScript and the Canvas API — no libraries, no frameworks. Just math, perception, and the physics of light translated into pixels.

How color spaces work: RGB vs HSL vs HSV

Before writing any color code, you need to understand why RGB is terrible for color theory and HSL is essential.

RGB (Red, Green, Blue) maps directly to how screens emit light: three phosphors at varying intensities. It's hardware-friendly but human-hostile. Want a darker version of blue? You need to reduce R, G, and B — but by how much? Want the complementary color of teal? You'd need to invert each channel, which gives you the complement in light mixing but not in perceptual color theory.

HSL (Hue, Saturation, Lightness) maps to how humans perceive color:

  • Hue — the color itself, expressed as an angle on the color wheel (0° = red, 120° = green, 240° = blue, 360° = back to red)
  • Saturation — how vivid the color is (0% = gray, 100% = pure color)
  • Lightness — how bright or dark (0% = black, 50% = pure color, 100% = white)

In HSL, every color theory operation becomes trivial. Complementary color? Add 180° to hue. Analogous palette? Add ±30°. Darker shade? Reduce lightness. More muted tone? Reduce saturation. This is why every example in this guide works in HSL space.

Example 1: Interactive color wheel

The color wheel is the foundation of all color theory. This example renders a full HSL wheel and lets you click to select colors, showing their HSL values and hex codes.

const C = document.createElement('canvas');
C.width = C.height = 400;
document.body.appendChild(C);
const ctx = C.getContext('2d');
const cx = 200, cy = 200, radius = 170;
let selected = { h: 0, s: 100, l: 50 };

function hslToHex(h, s, l) {
  s /= 100; l /= 100;
  const a = s * Math.min(l, 1 - l);
  const f = n => {
    const k = (n + h / 30) % 12;
    const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
    return Math.round(255 * color).toString(16).padStart(2, '0');
  };
  return '#' + f(0) + f(8) + f(4);
}

function drawWheel() {
  ctx.fillStyle = '#111';
  ctx.fillRect(0, 0, 400, 400);

  for (let angle = 0; angle < 360; angle += 0.5) {
    for (let r = 0; r < radius; r++) {
      const rad = angle * Math.PI / 180;
      const x = cx + r * Math.cos(rad);
      const y = cy + r * Math.sin(rad);
      const sat = (r / radius) * 100;
      ctx.fillStyle = 'hsl(' + angle + ', ' + sat + '%, ' + selected.l + '%)';
      ctx.fillRect(x, y, 2, 2);
    }
  }

  ctx.beginPath();
  ctx.arc(cx, cy, 30, 0, Math.PI * 2);
  ctx.fillStyle = 'hsl(' + selected.h + ', ' + selected.s + '%, ' + selected.l + '%)';
  ctx.fill();
  ctx.strokeStyle = '#fff';
  ctx.lineWidth = 2;
  ctx.stroke();

  for (let y = 30; y < 370; y++) {
    const l = ((y - 30) / 340) * 100;
    ctx.fillStyle = 'hsl(' + selected.h + ', ' + selected.s + '%, ' + l + '%)';
    ctx.fillRect(380, y, 15, 1);
  }

  const ly = 30 + (selected.l / 100) * 340;
  ctx.strokeStyle = '#fff';
  ctx.strokeRect(378, ly - 2, 19, 4);

  ctx.fillStyle = '#fff';
  ctx.font = '13px monospace';
  ctx.fillText('H: ' + selected.h.toFixed(0) + '°', 10, 385);
  ctx.fillText('S: ' + selected.s.toFixed(0) + '%', 120, 385);
  ctx.fillText('L: ' + selected.l.toFixed(0) + '%', 230, 385);
  ctx.fillText(hslToHex(selected.h, selected.s, selected.l), 330, 385);
}

C.addEventListener('click', function(e) {
  var rect = C.getBoundingClientRect();
  var x = (e.clientX - rect.left) * (400 / rect.width);
  var y = (e.clientY - rect.top) * (400 / rect.height);

  if (x > 375) {
    selected.l = Math.max(0, Math.min(100, ((y - 30) / 340) * 100));
  } else {
    var dx = x - cx, dy = y - cy;
    var dist = Math.sqrt(dx * dx + dy * dy);
    if (dist < radius) {
      selected.h = ((Math.atan2(dy, dx) * 180 / Math.PI) + 360) % 360;
      selected.s = Math.min(100, (dist / radius) * 100);
    }
  }
  drawWheel();
});

drawWheel();

The color wheel reveals a key insight: hue is circular. Red (0°) is adjacent to both orange (30°) and magenta (330°). This circularity is why modular arithmetic — adding angles and wrapping with % 360 — is the fundamental operation in algorithmic color theory. Every harmony formula in this guide is just angle arithmetic on this wheel.

Example 2: Color harmonies visualizer

Color harmonies are geometric relationships on the color wheel. This example visualizes all five classical harmonies for any base color.

const C = document.createElement('canvas');
C.width = 600; C.height = 400;
document.body.appendChild(C);
const ctx = C.getContext('2d');
var baseHue = 0;

var harmonies = {
  complementary: function(h) { return [h, (h + 180) % 360]; },
  analogous: function(h) { return [(h - 30 + 360) % 360, h, (h + 30) % 360]; },
  triadic: function(h) { return [h, (h + 120) % 360, (h + 240) % 360]; },
  splitComp: function(h) { return [h, (h + 150) % 360, (h + 210) % 360]; },
  tetradic: function(h) { return [h, (h + 90) % 360, (h + 180) % 360, (h + 270) % 360]; },
};

function draw() {
  ctx.fillStyle = '#111';
  ctx.fillRect(0, 0, 600, 400);

  var names = Object.keys(harmonies);
  var wheelR = 55, spacing = 120;

  names.forEach(function(name, i) {
    var ox = 70 + i * spacing;
    var oy = 140;
    var hues = harmonies[name](baseHue);

    for (var a = 0; a < 360; a += 1) {
      var rad = a * Math.PI / 180;
      ctx.beginPath();
      ctx.arc(ox, oy, wheelR, rad, rad + 0.02);
      ctx.arc(ox, oy, wheelR - 12, rad + 0.02, rad, true);
      ctx.fillStyle = 'hsl(' + a + ', 80%, 55%)';
      ctx.fill();
    }

    ctx.beginPath();
    hues.forEach(function(h, j) {
      var rad = h * Math.PI / 180;
      var x = ox + (wheelR - 6) * Math.cos(rad);
      var y = oy + (wheelR - 6) * Math.sin(rad);
      j === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    });
    ctx.closePath();
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = 2;
    ctx.stroke();

    hues.forEach(function(h) {
      var rad = h * Math.PI / 180;
      ctx.beginPath();
      ctx.arc(ox + (wheelR - 6) * Math.cos(rad),
              oy + (wheelR - 6) * Math.sin(rad), 5, 0, Math.PI * 2);
      ctx.fillStyle = 'hsl(' + h + ', 80%, 55%)';
      ctx.fill();
      ctx.strokeStyle = '#fff';
      ctx.lineWidth = 1.5;
      ctx.stroke();
    });

    ctx.fillStyle = '#aaa';
    ctx.font = '11px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText(name, ox, oy + wheelR + 20);

    var sw = Math.min(30, (spacing - 10) / hues.length);
    hues.forEach(function(h, j) {
      ctx.fillStyle = 'hsl(' + h + ', 80%, 55%)';
      ctx.fillRect(ox - (hues.length * sw) / 2 + j * sw, oy + wheelR + 30, sw - 2, 20);
    });
  });

  ctx.fillStyle = '#888';
  ctx.font = '12px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText('Click anywhere to change base hue', 300, 380);

  ctx.fillStyle = 'hsl(' + baseHue + ', 80%, 55%)';
  ctx.fillRect(10, 10, 580, 8);
  var indicator = 10 + (baseHue / 360) * 580;
  ctx.strokeStyle = '#fff';
  ctx.lineWidth = 2;
  ctx.strokeRect(indicator - 3, 8, 6, 12);
}

C.addEventListener('mousemove', function(e) {
  if (e.buttons !== 1) return;
  var rect = C.getBoundingClientRect();
  var x = (e.clientX - rect.left) * (600 / rect.width);
  baseHue = (x / 600) * 360;
  draw();
});
C.addEventListener('click', function(e) {
  var rect = C.getBoundingClientRect();
  baseHue = ((e.clientX - rect.left) * (600 / rect.width) / 600) * 360;
  draw();
});

draw();

Notice how each harmony is defined by a single function that takes a hue and returns an array of hues. Complementary adds 180° (opposite on the wheel). Analogous stays within ±30° (neighbors). Triadic divides the wheel into three equal segments at 120° apart. Split-complementary is like complementary but splits the opposite into two flanking colors at ±150°/210°. Tetradic divides into four quadrants at 90° intervals. These aren't arbitrary rules — they're symmetry operations on a circle, and symmetry is inherently pleasing to human perception.

Example 3: Procedural palette generator

This example generates complete 5-color palettes using different algorithms. Each palette is harmonious but distinct, and you can generate infinite variations.

const C = document.createElement('canvas');
C.width = 500; C.height = 400;
document.body.appendChild(C);
const ctx = C.getContext('2d');

function hslToRgb(h, s, l) {
  s /= 100; l /= 100;
  var a = s * Math.min(l, 1 - l);
  var f = function(n) {
    var k = (n + h / 30) % 12;
    return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
  };
  return [f(0) * 255, f(8) * 255, f(4) * 255];
}

function luminance(r, g, b) {
  var cs = [r, g, b].map(function(c) {
    c /= 255;
    return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * cs[0] + 0.7152 * cs[1] + 0.0722 * cs[2];
}

var strategies = [
  { name: 'Warm sunset',
    gen: function() {
      var base = 10 + Math.random() * 30;
      return [0,1,2,3,4].map(function(i) {
        return { h: (base + i * 12) % 360, s: 70 + Math.random() * 20, l: 30 + i * 12 };
      });
    }},
  { name: 'Cool ocean',
    gen: function() {
      var base = 180 + Math.random() * 40;
      return [0,1,2,3,4].map(function(i) {
        return { h: (base + i * 8 - 16) % 360, s: 50 + Math.random() * 30, l: 25 + i * 13 };
      });
    }},
  { name: 'Triadic bold',
    gen: function() {
      var base = Math.random() * 360;
      var hues = [base, (base + 120) % 360, (base + 240) % 360];
      return [
        { h: hues[0], s: 75, l: 45 },
        { h: hues[0], s: 60, l: 70 },
        { h: hues[1], s: 70, l: 50 },
        { h: hues[2], s: 65, l: 40 },
        { h: hues[2], s: 50, l: 75 },
      ];
    }},
  { name: 'Monochrome',
    gen: function() {
      var h = Math.random() * 360;
      return [0,1,2,3,4].map(function(i) {
        return { h: h, s: 30 + i * 15, l: 20 + i * 15 };
      });
    }},
  { name: 'Complementary',
    gen: function() {
      var h = Math.random() * 360;
      var comp = (h + 180) % 360;
      return [
        { h: h, s: 70, l: 35 },
        { h: h, s: 60, l: 55 },
        { h: h, s: 40, l: 80 },
        { h: comp, s: 65, l: 45 },
        { h: comp, s: 55, l: 65 },
      ];
    }},
];

var currentPalettes = [];

function generate() {
  currentPalettes = strategies.map(function(s) {
    return { name: s.name, colors: s.gen() };
  });
  draw();
}

function draw() {
  ctx.fillStyle = '#111';
  ctx.fillRect(0, 0, 500, 400);

  currentPalettes.forEach(function(palette, row) {
    var y = 10 + row * 75;
    ctx.fillStyle = '#888';
    ctx.font = '12px sans-serif';
    ctx.fillText(palette.name, 10, y + 15);

    palette.colors.forEach(function(c, col) {
      var x = 140 + col * 68;
      ctx.fillStyle = 'hsl(' + c.h + ', ' + c.s + '%, ' + c.l + '%)';
      ctx.beginPath();
      ctx.roundRect(x, y, 62, 50, 6);
      ctx.fill();

      var rgb = hslToRgb(c.h, c.s, c.l);
      var lum = luminance(rgb[0], rgb[1], rgb[2]);
      ctx.fillStyle = lum > 0.4 ? '#111' : '#fff';
      ctx.font = '9px monospace';
      ctx.textAlign = 'center';
      var hex = '#' + rgb.map(function(v) { return Math.round(v).toString(16).padStart(2, '0'); }).join('');
      ctx.fillText(hex, x + 31, y + 30);
      ctx.textAlign = 'left';
    });
  });

  ctx.fillStyle = '#666';
  ctx.font = '12px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText('Click to generate new palettes', 250, 390);
  ctx.textAlign = 'left';
}

C.addEventListener('click', generate);
generate();

Each strategy encodes a different aesthetic principle. Warm sunset stays in the red-orange range (0°-60°) with increasing lightness — the same gradient you see at dusk. Cool ocean hugs the cyan-blue range (180°-220°) with variation in saturation. Monochrome uses a single hue with varying saturation and lightness — this is the simplest palette that always works. The triadic and complementary strategies use geometric harmony relationships, but then split each chosen hue into light and dark variants to create depth.

Example 4: Color interpolation and gradients

Smooth color transitions are essential for generative art. This example shows why interpolating in HSL produces better results than RGB, and implements both linear and perceptually uniform gradients.

const C = document.createElement('canvas');
C.width = 500; C.height = 350;
document.body.appendChild(C);
const ctx = C.getContext('2d');

function lerpRGB(c1, c2, t) {
  return [
    c1[0] + (c2[0] - c1[0]) * t,
    c1[1] + (c2[1] - c1[1]) * t,
    c1[2] + (c2[2] - c1[2]) * t,
  ];
}

function lerpHSL(h1, s1, l1, h2, s2, l2, t) {
  var dh = h2 - h1;
  if (dh > 180) dh -= 360;
  if (dh < -180) dh += 360;
  return [
    ((h1 + dh * t) + 360) % 360,
    s1 + (s2 - s1) * t,
    l1 + (l2 - l1) * t,
  ];
}

function hslToRgb(h, s, l) {
  s /= 100; l /= 100;
  var a = s * Math.min(l, 1 - l);
  var f = function(n) {
    var k = (n + h / 30) % 12;
    return Math.round(255 * (l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)));
  };
  return [f(0), f(8), f(4)];
}

function rgbToHsl(r, g, b) {
  r /= 255; g /= 255; b /= 255;
  var max = Math.max(r, g, b), min = Math.min(r, g, b);
  var h, s, l = (max + min) / 2;
  if (max === min) { h = s = 0; }
  else {
    var d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
    else if (max === g) h = ((b - r) / d + 2) / 6;
    else h = ((r - g) / d + 4) / 6;
    h *= 360;
  }
  return [h, s * 100, l * 100];
}

var pairs = [
  { name: 'Red to Cyan', c1: [255, 50, 50], c2: [50, 220, 220] },
  { name: 'Yellow to Purple', c1: [255, 230, 50], c2: [140, 50, 255] },
  { name: 'Green to Magenta', c1: [50, 200, 80], c2: [230, 50, 180] },
  { name: 'Orange to Blue', c1: [255, 150, 30], c2: [30, 100, 255] },
];

function draw() {
  ctx.fillStyle = '#111';
  ctx.fillRect(0, 0, 500, 350);

  ctx.fillStyle = '#888';
  ctx.font = '11px sans-serif';
  ctx.fillText('RGB interpolation', 180, 18);
  ctx.fillText('HSL interpolation', 350, 18);

  pairs.forEach(function(pair, i) {
    var y = 30 + i * 80;
    ctx.fillStyle = '#666';
    ctx.font = '11px sans-serif';
    ctx.fillText(pair.name, 10, y + 28);

    var w = 160, h = 40;
    var hsl1 = rgbToHsl(pair.c1[0], pair.c1[1], pair.c1[2]);
    var hsl2 = rgbToHsl(pair.c2[0], pair.c2[1], pair.c2[2]);

    for (var x = 0; x < w; x++) {
      var t = x / w;
      var rgb = lerpRGB(pair.c1, pair.c2, t);
      ctx.fillStyle = 'rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ')';
      ctx.fillRect(140 + x, y, 1, h);
    }

    for (var x = 0; x < w; x++) {
      var t = x / w;
      var res = lerpHSL(hsl1[0], hsl1[1], hsl1[2], hsl2[0], hsl2[1], hsl2[2], t);
      ctx.fillStyle = 'hsl(' + res[0] + ', ' + res[1] + '%, ' + res[2] + '%)';
      ctx.fillRect(310 + x, y, 1, h);
    }

    ctx.fillStyle = '#555';
    ctx.font = '9px monospace';
    ctx.fillText('RGB', 140, y + h + 12);
    ctx.fillText('HSL', 310, y + h + 12);
  });
}

draw();

The difference is dramatic. RGB interpolation between complementary colors (like red to cyan or yellow to purple) passes through muddy grays — because the midpoint in RGB space is literally gray. HSL interpolation travels around the color wheel, passing through vivid intermediate hues. Red to cyan goes through yellow and green instead of through brown. This is why generative artists always interpolate in HSL (or its cousin OKLCH for perceptual uniformity).

Example 5: Color temperature and mood

Color temperature — warm (reds, oranges, yellows) versus cool (blues, cyans, purples) — is the most powerful emotional lever in visual design. This example generates scene-appropriate palettes based on mood.

const C = document.createElement('canvas');
C.width = 500; C.height = 400;
document.body.appendChild(C);
const ctx = C.getContext('2d');

var moods = {
  sunrise:   { hRange: [15, 55],   sRange: [60, 90],  lRange: [40, 75], bg: [20, 30, 20] },
  ocean:     { hRange: [185, 220], sRange: [40, 80],  lRange: [30, 65], bg: [210, 50, 15] },
  forest:    { hRange: [90, 150],  sRange: [30, 70],  lRange: [20, 50], bg: [120, 40, 12] },
  night:     { hRange: [230, 280], sRange: [30, 60],  lRange: [10, 35], bg: [250, 40, 8] },
  autumn:    { hRange: [10, 45],   sRange: [50, 85],  lRange: [25, 55], bg: [25, 50, 15] },
  cyberpunk: { hRange: [280, 340], sRange: [70, 100], lRange: [35, 65], bg: [300, 80, 10] },
};

var currentMood = 'sunrise';

function rand(min, max) { return min + Math.random() * (max - min); }

function drawMood(mood) {
  var m = moods[mood];

  for (var y = 0; y < 280; y++) {
    var t = y / 280;
    var l = m.bg[2] + t * 8;
    ctx.fillStyle = 'hsl(' + m.bg[0] + ', ' + m.bg[1] + '%, ' + l + '%)';
    ctx.fillRect(0, y, 500, 1);
  }

  for (var i = 0; i < 40; i++) {
    var h = rand(m.hRange[0], m.hRange[1]);
    var s = rand(m.sRange[0], m.sRange[1]);
    var l = rand(m.lRange[0], m.lRange[1]);
    var x = Math.random() * 500;
    var y = Math.random() * 260;
    var r = 5 + Math.random() * 25;

    ctx.beginPath();
    ctx.arc(x, y + 10, r, 0, Math.PI * 2);
    ctx.fillStyle = 'hsla(' + h + ', ' + s + '%, ' + l + '%, 0.7)';
    ctx.fill();
  }

  ctx.fillStyle = '#1a1a1a';
  ctx.fillRect(0, 280, 500, 120);

  var palette = [0,1,2,3,4,5,6].map(function() {
    return {
      h: rand(m.hRange[0], m.hRange[1]),
      s: rand(m.sRange[0], m.sRange[1]),
      l: rand(m.lRange[0], m.lRange[1]),
    };
  }).sort(function(a, b) { return a.l - b.l; });

  palette.forEach(function(c, i) {
    ctx.fillStyle = 'hsl(' + c.h + ', ' + c.s + '%, ' + c.l + '%)';
    ctx.beginPath();
    ctx.roundRect(15 + i * 69, 295, 62, 45, 5);
    ctx.fill();
  });

  var moodNames = Object.keys(moods);
  moodNames.forEach(function(name, i) {
    var x = 15 + i * 80;
    ctx.fillStyle = name === mood ? '#fff' : '#555';
    ctx.font = name === mood ? 'bold 11px sans-serif' : '11px sans-serif';
    ctx.fillText(name, x, 370);
  });

  ctx.fillStyle = '#444';
  ctx.font = '11px sans-serif';
  ctx.fillText('Click a mood to regenerate', 170, 392);
}

C.addEventListener('click', function(e) {
  var rect = C.getBoundingClientRect();
  var x = (e.clientX - rect.left) * (500 / rect.width);
  var y = (e.clientY - rect.top) * (400 / rect.height);

  if (y > 355) {
    var moodNames = Object.keys(moods);
    var idx = Math.floor((x - 15) / 80);
    if (idx >= 0 && idx < moodNames.length) {
      currentMood = moodNames[idx];
    }
  }
  drawMood(currentMood);
});

drawMood(currentMood);

Each mood is defined by three constrained ranges: hue, saturation, and lightness. Sunrise stays in warm oranges with high saturation and medium lightness. Night uses deep blues and purples with low saturation and low lightness. Cyberpunk cranks saturation to maximum in the magenta-violet range. The constraint is what creates coherence — unlimited random colors feel chaotic, but random colors within a narrow range feel intentional and harmonious.

Example 6: Contrast and accessibility checker

Color theory isn't just about beauty — it's about legibility. The WCAG (Web Content Accessibility Guidelines) defines minimum contrast ratios for text readability. This example builds a real-time contrast checker.

const C = document.createElement('canvas');
C.width = 500; C.height = 350;
document.body.appendChild(C);
const ctx = C.getContext('2d');

var fgHue = 220, bgHue = 50;
var fgLight = 35, bgLight = 90;

function hslToRgb(h, s, l) {
  s /= 100; l /= 100;
  var a = s * Math.min(l, 1 - l);
  var f = function(n) {
    var k = (n + h / 30) % 12;
    return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
  };
  return [f(0), f(8), f(4)];
}

function relLuminance(r, g, b) {
  var cs = [r, g, b].map(function(c) {
    return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * cs[0] + 0.7152 * cs[1] + 0.0722 * cs[2];
}

function contrastRatio(l1, l2) {
  return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}

function draw() {
  var bgRgb = hslToRgb(bgHue, 60, bgLight);
  var fgRgb = hslToRgb(fgHue, 70, fgLight);
  var bgLum = relLuminance(bgRgb[0], bgRgb[1], bgRgb[2]);
  var fgLum = relLuminance(fgRgb[0], fgRgb[1], fgRgb[2]);
  var ratio = contrastRatio(bgLum, fgLum);

  ctx.fillStyle = 'hsl(' + bgHue + ', 60%, ' + bgLight + '%)';
  ctx.fillRect(0, 0, 500, 220);

  ctx.fillStyle = 'hsl(' + fgHue + ', 70%, ' + fgLight + '%)';
  ctx.font = 'bold 28px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText('The quick brown fox', 250, 70);
  ctx.font = '18px sans-serif';
  ctx.fillText('jumps over the lazy dog', 250, 105);
  ctx.font = '14px sans-serif';
  ctx.fillText('Small text needs higher contrast', 250, 140);

  ctx.font = 'bold 36px monospace';
  ctx.fillText(ratio.toFixed(2) + ':1', 250, 195);

  var badges = [
    { label: 'AA Large', pass: ratio >= 3 },
    { label: 'AA Normal', pass: ratio >= 4.5 },
    { label: 'AAA Large', pass: ratio >= 4.5 },
    { label: 'AAA Normal', pass: ratio >= 7 },
  ];

  badges.forEach(function(b, i) {
    var x = 95 + i * 100;
    ctx.fillStyle = b.pass ? '#2d8a4e' : '#8a2d2d';
    ctx.beginPath();
    ctx.roundRect(x, 205, 80, 18, 4);
    ctx.fill();
    ctx.fillStyle = '#fff';
    ctx.font = '10px sans-serif';
    ctx.fillText(b.label + (b.pass ? ' OK' : ' X'), x + 40, 217);
  });

  ctx.fillStyle = '#1a1a1a';
  ctx.fillRect(0, 230, 500, 120);

  for (var x = 0; x < 220; x++) {
    ctx.fillStyle = 'hsl(' + ((x/220)*360) + ', 70%, ' + fgLight + '%)';
    ctx.fillRect(15 + x, 252, 1, 14);
  }
  ctx.strokeStyle = '#fff';
  ctx.strokeRect(15 + (fgHue/360)*220 - 2, 250, 4, 18);
  ctx.fillStyle = '#aaa'; ctx.font = '10px sans-serif'; ctx.textAlign = 'left';
  ctx.fillText('FG Hue', 15, 248);

  for (var x = 0; x < 220; x++) {
    ctx.fillStyle = 'hsl(' + ((x/220)*360) + ', 60%, ' + bgLight + '%)';
    ctx.fillRect(265 + x, 252, 1, 14);
  }
  ctx.strokeStyle = '#fff';
  ctx.strokeRect(265 + (bgHue/360)*220 - 2, 250, 4, 18);
  ctx.fillText('BG Hue', 265, 248);

  for (var x = 0; x < 220; x++) {
    ctx.fillStyle = 'hsl(' + fgHue + ', 70%, ' + ((x/220)*100) + '%)';
    ctx.fillRect(15 + x, 292, 1, 14);
  }
  ctx.strokeStyle = '#fff';
  ctx.strokeRect(15 + (fgLight/100)*220 - 2, 290, 4, 18);
  ctx.fillText('FG Light', 15, 288);

  for (var x = 0; x < 220; x++) {
    ctx.fillStyle = 'hsl(' + bgHue + ', 60%, ' + ((x/220)*100) + '%)';
    ctx.fillRect(265 + x, 292, 1, 14);
  }
  ctx.strokeStyle = '#fff';
  ctx.strokeRect(265 + (bgLight/100)*220 - 2, 290, 4, 18);
  ctx.fillText('BG Light', 265, 288);

  ctx.fillStyle = '#555'; ctx.font = '11px sans-serif'; ctx.textAlign = 'center';
  ctx.fillText('Drag sliders to adjust colors', 250, 340);
  ctx.textAlign = 'left';
}

C.addEventListener('mousemove', function(e) {
  if (e.buttons !== 1) return;
  var rect = C.getBoundingClientRect();
  var x = (e.clientX - rect.left) * (500 / rect.width);
  var y = (e.clientY - rect.top) * (350 / rect.height);

  if (y > 242 && y < 270) {
    if (x > 15 && x < 235) fgHue = ((x - 15) / 220) * 360;
    if (x > 265 && x < 485) bgHue = ((x - 265) / 220) * 360;
  }
  if (y > 282 && y < 310) {
    if (x > 15 && x < 235) fgLight = ((x - 15) / 220) * 100;
    if (x > 265 && x < 485) bgLight = ((x - 265) / 220) * 100;
  }
  draw();
});

draw();

The WCAG contrast algorithm is straightforward: compute relative luminance for both colors using a gamma-corrected formula, then divide the lighter by the darker. The thresholds — 3:1, 4.5:1, and 7:1 — come from human vision research on readability. Large text (18pt+) is more forgiving because the letter shapes are more recognizable even at lower contrast.

Example 7: Color mixing and blending modes

Physical paint mixing (subtractive color) works differently from light mixing (additive color). This example simulates both, plus demonstrates the blending modes used for compositing.

const C = document.createElement('canvas');
C.width = 500; C.height = 380;
document.body.appendChild(C);
const ctx = C.getContext('2d');

var blendModes = {
  multiply:  function(a, b) { return a * b; },
  screen:    function(a, b) { return 1 - (1 - a) * (1 - b); },
  overlay:   function(a, b) { return a < 0.5 ? 2 * a * b : 1 - 2 * (1 - a) * (1 - b); },
  softLight: function(a, b) { return b < 0.5 ? a - (1 - 2 * b) * a * (1 - a) : a + (2 * b - 1) * (Math.sqrt(a) - a); },
  hardLight: function(a, b) { return b < 0.5 ? 2 * a * b : 1 - 2 * (1 - a) * (1 - b); },
  difference:function(a, b) { return Math.abs(a - b); },
};

function hslToRgb(h, s, l) {
  s /= 100; l /= 100;
  var a = s * Math.min(l, 1 - l);
  var f = function(n) {
    var k = (n + h / 30) % 12;
    return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
  };
  return [f(0), f(8), f(4)];
}

var c1 = { h: 0, s: 80, l: 55 };
var c2 = { h: 210, s: 80, l: 55 };

function draw() {
  ctx.fillStyle = '#111';
  ctx.fillRect(0, 0, 500, 380);

  var rgb1 = hslToRgb(c1.h, c1.s, c1.l);
  var rgb2 = hslToRgb(c2.h, c2.s, c2.l);

  ctx.fillStyle = '#222';
  ctx.beginPath(); ctx.roundRect(10, 10, 230, 150, 8); ctx.fill();
  ctx.fillStyle = '#aaa'; ctx.font = '11px sans-serif';
  ctx.fillText('Additive (light)', 70, 28);

  ctx.globalCompositeOperation = 'lighter';
  ctx.beginPath(); ctx.arc(90, 100, 50, 0, Math.PI * 2);
  ctx.fillStyle = 'rgba(' + (rgb1[0]*255|0) + ',' + (rgb1[1]*255|0) + ',' + (rgb1[2]*255|0) + ',0.8)';
  ctx.fill();
  ctx.beginPath(); ctx.arc(150, 100, 50, 0, Math.PI * 2);
  ctx.fillStyle = 'rgba(' + (rgb2[0]*255|0) + ',' + (rgb2[1]*255|0) + ',' + (rgb2[2]*255|0) + ',0.8)';
  ctx.fill();
  ctx.globalCompositeOperation = 'source-over';

  ctx.fillStyle = '#222';
  ctx.beginPath(); ctx.roundRect(260, 10, 230, 150, 8); ctx.fill();
  ctx.fillStyle = '#aaa'; ctx.font = '11px sans-serif';
  ctx.fillText('Subtractive (paint)', 310, 28);

  var mixed = [
    blendModes.multiply(rgb1[0], rgb2[0]),
    blendModes.multiply(rgb1[1], rgb2[1]),
    blendModes.multiply(rgb1[2], rgb2[2]),
  ];

  ctx.beginPath(); ctx.arc(340, 100, 50, 0, Math.PI * 2);
  ctx.fillStyle = 'rgba(' + (rgb1[0]*255|0) + ',' + (rgb1[1]*255|0) + ',' + (rgb1[2]*255|0) + ',0.9)';
  ctx.fill();
  ctx.beginPath(); ctx.arc(400, 100, 50, 0, Math.PI * 2);
  ctx.fillStyle = 'rgba(' + (rgb2[0]*255|0) + ',' + (rgb2[1]*255|0) + ',' + (rgb2[2]*255|0) + ',0.9)';
  ctx.fill();

  ctx.fillStyle = '#aaa'; ctx.font = '11px sans-serif';
  ctx.fillText('Blend modes:', 10, 180);

  var modeNames = Object.keys(blendModes);
  modeNames.forEach(function(mode, i) {
    var col = i % 3, row = (i / 3) | 0;
    var x = 15 + col * 165, y = 190 + row * 90;

    ctx.fillStyle = '#222';
    ctx.beginPath(); ctx.roundRect(x, y, 155, 80, 5); ctx.fill();

    ctx.fillStyle = '#777'; ctx.font = '10px sans-serif';
    ctx.fillText(mode, x + 5, y + 14);

    for (var gx = 0; gx < 145; gx++) {
      var t = gx / 145;
      var r = blendModes[mode](rgb1[0], rgb1[0] * (1-t) + rgb2[0] * t);
      var g = blendModes[mode](rgb1[1], rgb1[1] * (1-t) + rgb2[1] * t);
      var b = blendModes[mode](rgb1[2], rgb1[2] * (1-t) + rgb2[2] * t);
      var cr = Math.max(0,Math.min(255,r*255))|0;
      var cg = Math.max(0,Math.min(255,g*255))|0;
      var cb = Math.max(0,Math.min(255,b*255))|0;
      ctx.fillStyle = 'rgb(' + cr + ',' + cg + ',' + cb + ')';
      ctx.fillRect(x + 5 + gx, y + 22, 1, 45);
    }
  });

  ctx.fillStyle = '#555'; ctx.font = '11px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText('Click to randomize colors', 250, 372);
  ctx.textAlign = 'left';
}

C.addEventListener('click', function() {
  c1.h = Math.random() * 360;
  c2.h = Math.random() * 360;
  draw();
});

draw();

The key insight: multiply always darkens (simulating paint mixing, where more pigment absorbs more light). Screen always lightens (simulating light mixing, where more sources add brightness). Overlay combines both — darkening darks and lightening lights, which is why it increases contrast. Understanding these mathematically lets you use them intentionally in generative art: multiply for shadows, screen for highlights, overlay for drama, softLight for subtle tinting.

Example 8: Animated color field

This final example brings everything together: an animated generative artwork that uses color theory principles to create a continuously evolving, harmonious color field.

const C = document.createElement('canvas');
C.width = 500; C.height = 400;
document.body.appendChild(C);
const ctx = C.getContext('2d');

var cols = 25, rows = 20;
var cw = C.width / cols, ch = C.height / rows;
var time = 0;
var harmonyMode = 0;
var modes = ['analogous', 'triadic', 'complementary', 'split'];

function noise2d(x, y) {
  var n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
  return n - Math.floor(n);
}

function smoothNoise(x, y) {
  var ix = Math.floor(x), iy = Math.floor(y);
  var fx = x - ix, fy = y - iy;
  var sx = fx * fx * (3 - 2 * fx);
  var sy = fy * fy * (3 - 2 * fy);
  var a = noise2d(ix, iy), b = noise2d(ix+1, iy);
  var c = noise2d(ix, iy+1), d = noise2d(ix+1, iy+1);
  return a + (b-a)*sx + (c-a)*sy + (a-b-c+d)*sx*sy;
}

function getHarmony(baseHue, mode) {
  if (mode === 0) return [baseHue, (baseHue + 25) % 360, (baseHue - 25 + 360) % 360];
  if (mode === 1) return [baseHue, (baseHue + 120) % 360, (baseHue + 240) % 360];
  if (mode === 2) return [baseHue, (baseHue + 180) % 360];
  return [baseHue, (baseHue + 150) % 360, (baseHue + 210) % 360];
}

function render() {
  time += 0.008;
  var baseHue = (time * 40) % 360;
  var hues = getHarmony(baseHue, harmonyMode);

  for (var row = 0; row < rows; row++) {
    for (var col = 0; col < cols; col++) {
      var nx = col * 0.15 + time;
      var ny = row * 0.15 + time * 0.7;
      var n = smoothNoise(nx, ny);

      var hueIdx = Math.floor(n * hues.length) % hues.length;
      var h = hues[hueIdx] + (n - 0.5) * 20;

      var n2 = smoothNoise(nx * 2.3 + 100, ny * 2.3 + 100);
      var s = 50 + n2 * 40;

      var n3 = smoothNoise(nx * 1.5 - time * 0.3, ny * 1.5 + 50);
      var l = 30 + n3 * 35;

      var scale = 0.7 + n * 0.4;
      var w = cw * scale, h2 = ch * scale;
      var x = col * cw + (cw - w) / 2;
      var y = row * ch + (ch - h2) / 2;

      ctx.fillStyle = 'hsl(' + h + ', ' + s + '%, ' + l + '%)';
      ctx.beginPath();
      ctx.roundRect(x, y, w, h2, 3);
      ctx.fill();
    }
  }

  ctx.fillStyle = 'rgba(0,0,0,0.6)';
  ctx.fillRect(0, 375, 500, 25);
  ctx.fillStyle = '#aaa';
  ctx.font = '11px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText('Harmony: ' + modes[harmonyMode] + ' — click to change', 250, 391);
  ctx.textAlign = 'left';

  requestAnimationFrame(render);
}

C.addEventListener('click', function() {
  harmonyMode = (harmonyMode + 1) % modes.length;
});

render();

This piece demonstrates why color theory matters for generative art. The animation uses the same noise field regardless of harmony mode — the shapes, motion, and layout stay identical. But switching from analogous to triadic to complementary transforms the entire mood. Analogous feels calm and natural. Triadic feels vibrant and energetic. Complementary creates tension and contrast. Split-complementary balances between them. The geometry is constant; only the color relationships change, and yet the experience is completely different each time.

Building your own palette algorithms

The examples above give you the building blocks. Here's how to combine them into a palette system for your own projects:

  • Start with a base hue. Choose one based on the emotion you want: warm hues (0°-60°) for energy and warmth, cool hues (180°-270°) for calm and depth, purples (270°-330°) for mystery and luxury.
  • Choose a harmony rule. Analogous for cohesion, complementary for contrast, triadic for vibrancy, monochrome for elegance.
  • Vary lightness to create hierarchy. Dark values recede, light values advance. Use 3-4 lightness levels for each hue to create depth without adding more hues.
  • Desaturate secondary colors. Your accent color should be the most saturated. Supporting colors work better at 60-70% of that saturation. This prevents the "rainbow soup" effect that plagues many generative art pieces.
  • Test at both extremes. Your palette should work on both dark and light backgrounds. If it doesn't, you probably need to adjust lightness values rather than hues.
  • Interpolate in HSL or OKLCH, never RGB. RGB interpolation produces muddy midpoints. HSL keeps colors vivid. OKLCH (if available) keeps perceived brightness constant, which is even better for gradients.

Advanced: perceptual uniformity and OKLCH

HSL has a known flaw: it treats all hues as equally bright, but human perception disagrees. Yellow at 50% lightness looks much brighter than blue at 50% lightness. This is because HSL is a geometric model, not a perceptual one.

The modern solution is OKLCH (Oklab Lightness Chroma Hue), a color space designed so that equal numeric differences correspond to equal perceived differences. In CSS, you can already use it:

/* These two colors have the same perceptual brightness */
color: oklch(70% 0.15 90);   /* warm yellow */
color: oklch(70% 0.15 250);  /* cool blue */

/* In HSL, you'd need different lightness values to match */
color: hsl(60, 80%, 50%);    /* yellow - looks bright */
color: hsl(240, 80%, 50%);   /* blue - looks much darker */

For generative art, OKLCH means your gradients maintain consistent brightness as they sweep through hues — no more dark bands in the blue region or washed-out yellows. Browser support for oklch() in CSS is now universal, and JavaScript implementations are straightforward (convert through linear sRGB to XYZ to Oklab to OKLCH).

Color theory in creative coding

Color is where mathematics meets human emotion. Every example in this guide is pure math — angles on a wheel, interpolation between coordinates, luminance formulas — but the output is felt, not calculated. A triadic palette doesn't look "120° apart"; it looks vibrant. A monochrome scheme doesn't look "constant hue"; it looks elegant.

The techniques here combine naturally with other creative coding approaches:

  • Use harmony algorithms to color fractal renders — map iteration count to a position on a complementary gradient
  • Apply mood palettes to Perlin noise flow fields — sunrise for warm flows, ocean for cool currents
  • Color particle systems by velocity or age using HSL interpolation
  • Use contrast checking to ensure ASCII art characters remain readable
  • Apply blend modes to layer fluid simulations for rich visual textures
  • Map mathematical patterns to perceptually uniform OKLCH gradients for consistent brightness

On Lumitree, color is what gives each micro-world its emotional signature. Two branches might use the same particle system code, but one uses a warm sunset palette and feels like a campfire, while the other uses a cool ocean palette and feels like deep water. The code generates the motion; the color generates the meaning. And all of it fits in under 50KB — because HSL math is cheap, harmony algorithms are tiny, and a well-chosen palette needs only 5-7 values to transform an entire visual experience.

Start with the color wheel (example 1) to build your intuition. Use the palette generator (example 3) to see how constraint-based randomness produces consistent results. Test your combinations with the contrast checker (example 6). Then watch the animated color field (example 8) and click through the harmony modes — same shapes, same motion, completely different worlds. That's the power of color theory in code.

Related articles