Learn Creative Coding (#7) - Color Theory for Coders

What will I learn?
- Why HSB is better than RGB for creative work;
- complementary, analogous, and triadic color schemes;
- generating harmonious palettes algorithmically;
- lerp for smooth gradients;
- practical techniques for making color choices that don't suck;
- golden angle distribution for mathematically optimal palettes;
- perceptual luminance correction using ITU-R BT.709;
- building a complete generative art piece with balanced color.
Requirements
- A modern web browser;
- Episodes #1-6 of this series.
Difficulty
- Beginner
Curriculum (of the Learn Creative Coding Series):
- Learn Creative Coding (#1) - What Is Creative Coding? (And Why You Should Care)
- Learn Creative Coding (#2) - Your First Sketch: Shapes, Colors, and the Canvas
- Learn Creative Coding (#3) - Movement and Time: Making Things Animate
- Learn Creative Coding (#4) - Randomness: The Secret Ingredient
- Learn Creative Coding (#5) - Loops and Grids: Repetition as a Design Tool
- Learn Creative Coding (#6) - Interactivity: Mouse, Keyboard, and Touch
- Learn Creative Coding (#7) - Color Theory for Coders (this post)
I used to pick colors by trying random RGB values until something looked okay. That's like tuning a guitar by randomly turning pegs until it sounds fine. It works eventually, but there's a much better way.
The problem with RGB
RGB (Red, Green, Blue) is how screens work — not how humans think about color. Quick: what RGB values make a warm sunset orange? fill(240, 150, 50)? fill(255, 130, 30)? You're guessing.
Now in HSB: "warm orange" is roughly hue 25, high saturation, high brightness. fill(25, 90, 95). Makes sense immediately.
Switch to HSB
function setup() {
createCanvas(500, 100);
colorMode(HSB, 360, 100, 100);
noStroke();
for (let x = 0; x < width; x++) {
fill(map(x, 0, width, 0, 360), 80, 90);
rect(x, 0, 1, height);
}
}
This draws the entire hue spectrum. Red on the left, through orange, yellow, green, cyan, blue, purple, back to red. One number controls the "color" — the other two control intensity and lightness.
Hue (0-360): the color on the wheel. 0=red, 60=yellow, 120=green, 180=cyan, 240=blue, 300=magenta.
Saturation (0-100): how vivid. 0=grey, 100=full color.
Brightness (0-100): how light. 0=black, 100=full brightness.
From now on we work in HSB. Put colorMode(HSB, 360, 100, 100) at the top of setup and forget RGB exists.
Color relationships
Here's where actual color theory comes in. Certain hue combinations are mathematically guaranteed to look harmonious. They're all based on the color wheel.
Complementary (opposite)
Two hues 180 degrees apart. Maximum contrast, high energy.
function setup() {
createCanvas(500, 300);
colorMode(HSB, 360, 100, 100);
background(0, 0, 95);
noStroke();
let baseHue = 200; // blue
let compHue = (baseHue + 180) % 360; // orange
fill(baseHue, 80, 85);
rect(50, 50, 180, 200);
fill(compHue, 80, 85);
rect(270, 50, 180, 200);
}
Blue and orange. Teal and coral. Purple and yellow. Always works.
Analogous (neighbors)
Three hues within 30-60 degrees of each other. Calm, cohesive.
function setup() {
createCanvas(500, 300);
colorMode(HSB, 360, 100, 100);
background(0, 0, 15);
noStroke();
let baseHue = 200;
fill(baseHue - 30, 70, 80);
rect(30, 50, 140, 200);
fill(baseHue, 80, 85);
rect(180, 50, 140, 200);
fill(baseHue + 30, 70, 80);
rect(330, 50, 140, 200);
}
Three shades of blue-to-cyan. Peaceful. This is what you want for backgrounds and atmospheric pieces.
Triadic (three evenly spaced)
Three hues 120 degrees apart. Vibrant but balanced.
function setup() {
createCanvas(500, 300);
colorMode(HSB, 360, 100, 100);
background(0, 0, 95);
noStroke();
let baseHue = 10; // red-orange
fill(baseHue, 80, 85);
ellipse(125, 150, 160, 160);
fill((baseHue + 120) % 360, 80, 85);
ellipse(250, 150, 160, 160);
fill((baseHue + 240) % 360, 80, 85);
ellipse(375, 150, 160, 160);
}
Red, green, blue — but more nuanced than that sounds. Triadic palettes are bold and work great for pieces with distinct visual elements.
Generating palettes algorithmically
Here's a function that creates a palette from any base hue:
function generatePalette(baseHue, scheme) {
let hues = [];
if (scheme === 'complementary') {
hues = [baseHue, (baseHue + 180) % 360];
} else if (scheme === 'analogous') {
hues = [baseHue - 30, baseHue, baseHue + 30];
} else if (scheme === 'triadic') {
hues = [baseHue, (baseHue + 120) % 360, (baseHue + 240) % 360];
} else if (scheme === 'split-complementary') {
hues = [baseHue, (baseHue + 150) % 360, (baseHue + 210) % 360];
}
// add saturation and brightness variations
let palette = [];
for (let h of hues) {
palette.push(color((h + 360) % 360, 80, 90));
palette.push(color((h + 360) % 360, 50, 70)); // muted variant
}
return palette;
}
Call it with any hue and scheme, get back a usable palette. The muted variants (lower saturation, lower brightness) give you shading options.
Lerp: smooth gradients
lerpColor() blends between two colors smoothly:
function setup() {
createCanvas(500, 200);
colorMode(HSB, 360, 100, 100);
let c1 = color(200, 80, 90); // blue
let c2 = color(350, 80, 90); // pink
noStroke();
for (let x = 0; x < width; x++) {
let t = x / width;
fill(lerpColor(c1, c2, t));
rect(x, 0, 1, height);
}
}
A smooth gradient from blue to pink. The t value goes from 0 (100% first color) to 1 (100% second color). This is lerp (linear interpolation) applied to color.
Multi-stop gradients:
function multiGradient(colors, t) {
let segment = 1 / (colors.length - 1);
let index = floor(t / segment);
index = constrain(index, 0, colors.length - 2);
let localT = (t - index * segment) / segment;
return lerpColor(colors[index], colors[index + 1], localT);
}
function setup() {
createCanvas(500, 200);
colorMode(HSB, 360, 100, 100);
let stops = [
color(240, 80, 30), // dark blue
color(200, 60, 60), // medium blue
color(40, 80, 95), // orange
color(50, 90, 100), // yellow
];
noStroke();
for (let x = 0; x < width; x++) {
fill(multiGradient(stops, x / width));
rect(x, 0, 1, height);
}
}
A sunset gradient with four color stops. This function works with any number of colors and you'll reuse it in countless projects.
Practical palette techniques
The 60-30-10 rule
Designers use this: 60% dominant color, 30% secondary, 10% accent. In code:
function setup() {
createCanvas(500, 500);
colorMode(HSB, 360, 100, 100);
background(220, 15, 95); // 60% — light blue-grey
noStroke();
// 30% — medium blue shapes
fill(220, 50, 70);
for (let i = 0; i < 30; i++) {
ellipse(random(width), random(height), random(30, 80), random(30, 80));
}
// 10% — accent orange
fill(25, 90, 95);
for (let i = 0; i < 8; i++) {
ellipse(random(width), random(height), random(10, 30), random(10, 30));
}
}
The orange pops precisely because there's so little of it. If you made everything orange, nothing would stand out.
Desaturation for depth
Lower saturation on background elements, full saturation on foreground:
function setup() {
createCanvas(500, 500);
colorMode(HSB, 360, 100, 100);
background(0, 0, 10);
noStroke();
// background layer — low sat
for (let i = 0; i < 100; i++) {
fill(200 + random(-20, 20), 20, 30, 50);
ellipse(random(width), random(height), random(20, 60), random(20, 60));
}
// midground — medium sat
for (let i = 0; i < 40; i++) {
fill(200 + random(-10, 10), 50, 60, 70);
ellipse(random(width), random(height), random(10, 30), random(10, 30));
}
// foreground — full sat
for (let i = 0; i < 15; i++) {
fill(30 + random(-10, 10), 90, 95);
ellipse(random(width), random(height), random(5, 15), random(5, 15));
}
}
This creates depth. Your eye is drawn to the bright, saturated foreground dots while the desaturated background recedes. Painters have used this trick for centuries — it's called aerial perspective.
Stealing palettes
No shame in this. Find art, photos, or design you love and extract the colors. Sites like coolors.co and colormind.io generate palettes. Or sample from a photo:
let palette = [
'#2d3436', '#636e72', '#b2bec3', '#dfe6e9', '#e17055', '#d63031'
];
Hardcode five or six hex values and use random(palette) to pick from them. This is how most professional generative artists work — they curate palettes by eye, not by algorithm.
Going deeper: the golden ratio and perceptual luminance
Okay so the stuff above is "color theory 101" — the basics every designer knows. But we're coders, so lets go deeper and use some actual math to generate better palettes than most humans can pick by hand.
Golden angle distribution
Here's a problem: you want to generate N colors that are maximally spread across the hue wheel. Evenly spacing them works (360/N degrees apart), but if you later add more colors, the whole palette shifts. There's a better approach from nature — the golden angle.
The golden angle is 360 / φ² where φ (phi) is the golden ratio (1 + √5) / 2 ≈ 1.618. That gives us approximately 137.508 degrees. Sunflowers use this exact angle to pack seeds — each new seed rotates 137.5° from the last, and they never overlap no matter how many you add.
function setup() {
createCanvas(600, 600);
colorMode(HSB, 360, 100, 100);
background(0, 0, 10);
let goldenAngle = 137.508;
let numColors = 12;
// generate palette using golden angle
let palette = [];
for (let i = 0; i < numColors; i++) {
let hue = (i * goldenAngle) % 360;
palette.push(hue);
}
// draw color wheel with our distributed hues
noStroke();
let cx = width / 2;
let cy = height / 2;
let radius = 220;
for (let i = 0; i < palette.length; i++) {
let angle = radians(palette[i]);
let x = cx + cos(angle) * radius;
let y = cy + sin(angle) * radius;
fill(palette[i], 85, 90);
ellipse(x, y, 40, 40);
// label with hue value
fill(0, 0, 80);
textAlign(CENTER);
textSize(10);
let lx = cx + cos(angle) * (radius + 30);
let ly = cy + sin(angle) * (radius + 30);
text(nf(palette[i], 0, 1) + '°', lx, ly);
}
// draw the wheel outline
noFill();
stroke(0, 0, 30);
ellipse(cx, cy, radius * 2, radius * 2);
}
Run this. You'll get 12 hues beautifully spread across the wheel — and here's the key: you can change numColors to 5, 8, 20, 50, whatever, and the distribution ALWAYS looks good. No recalculation needed. That's the power of irrational numbers — they never repeat, so they never cluster.
Compare with 360 / N even spacing: if you go from 6 to 7 colors, every single hue shifts. With the golden angle, the first 6 stay exactly where they were and #7 just slots in between.
Perceptual luminance — why yellow looks brighter than blue
Here's something that'll change how you think about color: not all hues are equal brightness to the human eye. Pure yellow (hue 60) looks WAY brighter than pure blue (hue 240) even at the same saturation and brightness values. That's because our eyes have more green-sensitive cones than red or blue ones — an evolutionary leftover from needing to spot predators in foliage.
The ITU-R BT.709 standard (used in HDTV) defines perceptual luminance as:
L = 0.2126 × R + 0.7152 × G + 0.0722 × B
Those weights are NOT equal — green contributes 71.5% of perceived brightness! Lets build a function that compensates for this:
function perceivedBrightness(c) {
// extract RGB from a p5 color object
let r = red(c) / 255;
let g = green(c) / 255;
let b = blue(c) / 255;
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function adjustForPerception(hue, targetLuminance) {
// binary search for the brightness value that gives
// us the target perceptual luminance at this hue
let lo = 0, hi = 100;
for (let i = 0; i < 20; i++) {
let mid = (lo + hi) / 2;
let c = color(hue, 80, mid);
let lum = perceivedBrightness(c);
if (lum < targetLuminance) {
lo = mid;
} else {
hi = mid;
}
}
return (lo + hi) / 2;
}
function setup() {
createCanvas(600, 400);
colorMode(HSB, 360, 100, 100);
background(0, 0, 5);
noStroke();
let targetLum = 0.45;
// top row: naive (same HSB brightness)
for (let i = 0; i < 12; i++) {
let hue = (i * 30) % 360;
fill(hue, 80, 80);
rect(i * 50, 30, 45, 140);
}
fill(0, 0, 70);
textSize(12);
text('Same HSB brightness (80) — notice yellow screams, blue hides', 10, 20);
// bottom row: perceptually corrected
for (let i = 0; i < 12; i++) {
let hue = (i * 30) % 360;
let correctedBri = adjustForPerception(hue, targetLum);
fill(hue, 80, correctedBri);
rect(i * 50, 230, 45, 140);
}
fill(0, 0, 70);
text('Perceptually equal luminance — all hues look equally bright', 10, 220);
}
The top row is what most people do: same brightness value for every hue. Yellow screams at you while blue almost disappears. The bottom row uses the binary search to find what brightness each hue ACTUALLY needs to look equally bright. The difference is dramatic — this is how professional colorists work, and now you know the math behind it.
Putting it all together: a perceptually-balanced generative piece
Let's combine golden angle distribution + perceptual correction + everything we learned into one piece that generates a unique color-balanced composition:
let goldenAngle = 137.508;
let palette = [];
let shapes = [];
function setup() {
createCanvas(800, 800);
colorMode(HSB, 360, 100, 100);
// generate perceptually balanced palette
let baseHue = random(360);
let numColors = 7;
for (let i = 0; i < numColors; i++) {
let hue = (baseHue + i * goldenAngle) % 360;
let bri = adjustForPerception(hue, 0.5);
palette.push({
hue: hue,
sat: 75 + random(15),
bri: bri,
weight: i === 0 ? 0.5 : (i < 3 ? 0.3 : 0.15)
});
}
// generate shapes with 60-30-10 weighting
for (let i = 0; i < 200; i++) {
let ci = weightedColorPick(palette);
let c = palette[ci];
let layer = ci === 0 ? 'bg' : (ci < 3 ? 'mid' : 'fg');
let size = layer === 'bg' ? random(60, 200) :
layer === 'mid' ? random(20, 80) :
random(5, 30);
let satJitter = random(-10, 10);
let briJitter = random(-15, 10);
shapes.push({
x: random(width),
y: random(height),
size: size,
hue: c.hue,
sat: constrain(c.sat + satJitter, 20, 100),
bri: constrain(c.bri + briJitter, 10, 100),
alpha: layer === 'bg' ? random(20, 50) :
layer === 'mid' ? random(40, 75) : random(70, 100),
type: random() > 0.6 ? 'circle' : 'rect'
});
}
// sort by size so big shapes render first (painter's algorithm)
shapes.sort((a, b) => b.size - a.size);
// render
background(palette[0].hue, 15, 95);
noStroke();
for (let s of shapes) {
fill(s.hue, s.sat, s.bri, s.alpha / 100);
if (s.type === 'circle') {
ellipse(s.x, s.y, s.size);
} else {
push();
translate(s.x, s.y);
rotate(random(TWO_PI));
rectMode(CENTER);
rect(0, 0, s.size, s.size * random(0.5, 1.5));
pop();
}
}
}
function weightedColorPick(pal) {
let totalWeight = pal.reduce((sum, c) => sum + c.weight, 0);
let r = random(totalWeight);
let cumulative = 0;
for (let i = 0; i < pal.length; i++) {
cumulative += pal[i].weight;
if (r <= cumulative) return i;
}
return pal.length - 1;
}
function perceivedBrightness(c) {
let r = red(c) / 255;
let g = green(c) / 255;
let b = blue(c) / 255;
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function adjustForPerception(hue, targetLuminance) {
let lo = 0, hi = 100;
for (let i = 0; i < 20; i++) {
let mid = (lo + hi) / 2;
let c = color(hue, 80, mid);
let lum = perceivedBrightness(c);
if (lum < targetLuminance) lo = mid;
else hi = mid;
}
return (lo + hi) / 2;
}
Every time you run this, you get a unique composition with a mathematically balanced palette. The golden angle ensures the hues don't cluster, the perceptual correction means no single color dominates visually, and the weighted distribution gives you the 60-30-10 hierarchy automatically. Press Ctrl+Enter to regenerate endlessly — each one looks like someone spent 20 minutes picking colors by hand ;-)
Allez, wa weten we nu allemaal?
colorMode(HSB, 360, 100, 100)— switch to HSB and never look back- Hue = color (0-360), Saturation = vivid-ness (0-100), Brightness = lightness (0-100)
- Complementary (+180), analogous (+/-30), triadic (+120, +240) — guaranteed harmony
lerpColor()for smooth gradients between any two colors- 60-30-10 rule: dominant, secondary, accent
- Low saturation recedes, high saturation advances — use this for depth
- Steal palettes from photos and art — no shame, everyone does it
- Golden angle (137.508°) generates maximally-spread hues that scale to any N
- Perceptual luminance (
0.2126R + 0.7152G + 0.0722B) — use binary search to equalize perceived brightness across hues - Combine all of the above for generative art that looks professionally color-graded, automatically
Next episode: mini-project time. We'll combine everything from episodes 1-7 into a generative poster that creates a unique composition every time you run it. See you there. :-)
Merci voor het lezen!
X
Thanks for your contribution to the STEMsocial community. Feel free to join us on discord to get to know the rest of us!
Please consider delegating to the @stemsocial account (85% of the curation rewards are returned).
Consider setting @stemsocial as a beneficiary of this post's rewards if you would like to support the community and contribute to its mission of promoting science and education on Hive.
Congratulations @femdev! You have completed the following achievement on the Hive blockchain And have been rewarded with New badge(s)
Your next target is to reach 1000 upvotes.
You can view your badges on your board and compare yourself to others in the Ranking
If you no longer want to receive notifications, reply to this comment with the word
STOP