← Back to Blog

Color Theory for Developers: HEX/RGB/HSL, Accessibility, Contrast Ratios & Palette Generation

Why Should Developers Understand Color Theory?

Color isn't just the designer's domain. As a developer, understanding color models, contrast, and accessibility standards helps you:

  • Accurately implement designs without color distortion
  • Build WCAG-compliant accessible interfaces
  • Write dynamic theming and color-switching logic
  • Make sound color decisions when no designer is available

Color Models Explained

HEX (Hexadecimal)

The most common color notation on the web, formatted as #RRGGBB, where each pair is a hex value (00-FF) for a channel.

#FF0000  -> Pure red
#00FF00  -> Pure green
#0000FF  -> Pure blue

8-digit HEX includes an alpha channel: #RRGGBBAA, e.g., #FF000080 is semi-transparent red.

RGB / RGBA

An additive model using red, green, and blue channels (0-255).

color: rgb(255, 0, 0);
color: rgba(255, 0, 0, 0.5);

Modern CSS also supports space syntax: rgb(255 0 0 / 50%).

HSL / HSLA

HSL aligns with human intuition about color, with three components:

  • H (Hue): 0-360 degrees on the color wheel
  • S (Saturation): 0-100%, color intensity
  • L (Lightness): 0-100%, 0 is black, 100 is white
color: hsl(0, 100%, 50%);    /* Pure red */
color: hsl(120, 100%, 50%);  /* Pure green */
color: hsl(240, 100%, 50%);  /* Pure blue */

Model Conversion

HEX → RGB

function hexToRgb(hex) {
  const r = parseInt(hex.slice(1, 3), 16);
  const g = parseInt(hex.slice(3, 5), 16);
  const b = parseInt(hex.slice(5, 7), 16);
  return { r, g, b };
}

RGB → HSL

function rgbToHsl(r, g, b) {
  r /= 255; g /= 255; b /= 255;
  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  let h, s, l = (max + min) / 2;

  if (max === min) {
    h = s = 0;
  } else {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    switch (max) {
      case r: h = (g - b) / d + (g < b ? 6 : 0); break;
      case g: h = (b - r) / d + 2; break;
      case b: h = (r - g) / d + 4; break;
    }
    h /= 6;
  }
  return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
}

Accessibility and Contrast

WCAG Contrast Standards

The Web Content Accessibility Guidelines define minimum contrast ratios:

Level Normal Text Large Text (18pt+ or 14pt bold)
AA (minimum) 4.5:1 3:1
AAA (enhanced) 7:1 4.5:1

Contrast Calculation

Contrast is based on relative luminance:

function getLuminance(r, g, b) {
  const [rs, gs, bs] = [r, g, b].map(c => {
    c /= 255;
    return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}

function getContrastRatio(rgb1, rgb2) {
  const l1 = getLuminance(...rgb1);
  const l2 = getLuminance(...rgb2);
  const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
  return (lighter + 0.05) / (darker + 0.05);
}

Common Contrast Issues

  • Light gray text on white often fails (e.g., #999 on #FFF is only 2.85:1)
  • Brand colors used as button text need contrast verification
  • Dark mode also requires contrast checks

Palette Generation

Classic Color Schemes

Complementary

Two colors opposite on the color wheel (180° apart), strong contrast.

Analogous

Colors adjacent on the wheel (30°-60° apart), harmonious.

Triadic

Three colors equally spaced (120° apart), vibrant and balanced.

Monochromatic

Same hue with varying saturation and lightness for layered depth.

function generateMonochromatic(baseHue) {
  return [
    `hsl(${baseHue}, 80%, 90%)`,
    `hsl(${baseHue}, 70%, 70%)`,
    `hsl(${baseHue}, 80%, 50%)`,
    `hsl(${baseHue}, 70%, 30%)`,
    `hsl(${baseHue}, 60%, 15%)`
  ];
}

Programmatic Palette Generation with HSL

HSL is the best model for programmatic palette generation — just adjust the hue angle:

function generatePalette(baseHue, scheme = 'analogous') {
  const schemes = {
    complementary: [0, 180],
    analogous: [-30, 0, 30],
    triadic: [0, 120, 240],
    tetradic: [0, 90, 180, 270]
  };
  const offsets = schemes[scheme] || schemes.analogous;
  return offsets.map(offset =>
    `hsl(${(baseHue + offset + 360) % 360}, 65%, 55%)`
  );
}

Design Token Practice

Modern design systems use CSS variables to define color scales:

:root {
  --primary-50:  #eff6ff;
  --primary-100: #dbeafe;
  --primary-500: #3b82f6;
  --primary-700: #1d4ed8;
  --primary-900: #1e3a8a;
}

Advanced CSS Color Functions

color-mix()

.result { background: color-mix(in srgb, red 50%, blue 50%); }

color() for Different Color Spaces

.vivid { color: color(display-p3 1 0 0); }
.darker { color: hsl(from var(--base) h s calc(l - 10%)); }

prefers-color-scheme for Dark Mode

:root { --bg: #ffffff; --text: #1a1a1a; }

@media (prefers-color-scheme: dark) {
  :root { --bg: #1a1a1a; --text: #ffffff; }
}

body { background: var(--bg); color: var(--text); }

Tools and Resources

Tool Purpose
Coolors Palette generation
Adobe Color Color wheel and rules
WebAIM Contrast Checker Contrast checking
OKLCH Picker OKLCH color space picker
Advertisement

Frequently Asked Questions

When should I use HSL versus RGB?

**RGB describes how to mix the colour; HSL describes what the colour is.** For theming and generating a family of tints and shades, HSL is far more convenient — change L for a full ramp, change H to shift hue. For pixel-exact reproduction (sampling a design mock, image processing) use RGB or hex. CSS accepts both, and `hsl(200 80% 45%)` is much more tweakable than `#1a7fc1`. The [colour converter](/color-converter.html) converts between them.

What contrast ratio counts as accessible?

WCAG 2.1 AA requires: **4.5:1 for body text**, **3:1 for large text** (18pt regular or 14pt bold and above), and **3:1 for non-text elements** (icons, input borders, chart fills). Level AAA raises these to 7:1 for body text and 4.5:1 for large text. Note this is a *relative contrast ratio*, not a brightness difference — light grey `#999` on white is only 2.8:1, a common combination that looks fine but fails.

Why does a colour from the design mock look different in CSS?

Four common causes: (1) **different colour spaces** — design tools may work in sRGB or Display P3 while browsers default to sRGB, so vivid P3 colours get compressed; (2) **alpha compositing** — a semi-transparent layer in the mock sits on a background, so you must compute the composited result yourself; (3) **no dark-mode variant** — only the light value was defined, so contrast inverts under a dark theme; (4) ambient light and display calibration differences. Debug by first confirming both sides agree on the hex value, then checking for transparency.

What should I watch for when designing a dark theme?

Three hard rules: (1) **never pair pure black `#000` with pure white `#fff`** — the contrast causes halation; use a dark grey such as `#121212`. (2) **lower the saturation** — colours that look great on a light background glow and muddy on a dark one, so reduce saturation by roughly 10–20%. (3) **do not encode hierarchy with hue** — express elevation by surface lightness, so nearer layers are lighter; a ramp like `#121212` → `#1e1e1e` → `#2c2c2c` reads more clearly than switching colours.

← Back to Blog