Toolmingo
Guides8 min read

How to Pick Colors for a Website (Color Theory for Developers)

Learn how to choose website colors using color theory, HSL math, WCAG contrast ratios, and code examples in JavaScript, Python, Go, and PHP.

How to Pick Colors for a Website

Color choice is one of the highest-leverage decisions in web design. A coherent palette makes a site look intentional; a random one makes it look broken — even if all the code is perfect. This guide walks through the color models developers need to know, the schemes that work, the contrast rules that are required by law in many countries, and the code to implement all of it.


The Three Color Models

HEX (#RRGGBB)

The format you see most in CSS. Each pair is a hex byte (00–FF) for red, green, and blue.

#1a73e8  →  R=26  G=115  B=232

Good for: copy-pasting between tools, CSS values, design tokens.
Bad for: arithmetic. Adding #0000ff to #ff0000 does not give you a useful middle colour.

RGB (rgb(R, G, B))

Channels are integers 0–255. Useful in JavaScript when you need to manipulate individual channels.

color: rgb(26, 115, 232);
background: rgba(26, 115, 232, 0.15);  /* with opacity */

HSL (hsl(H, S%, L%))

The model that matches how humans think about colour:

Component Range Meaning
Hue 0–360° The colour (0=red, 120=green, 240=blue)
Saturation 0–100% 0% = grey, 100% = full colour
Lightness 0–100% 0% = black, 50% = base colour, 100% = white
color: hsl(214, 80%, 51%);   /* the same blue as above */

HSL is the best model for programmatic colour work. Rotating hue by 30° gives an analogous colour. Rotating by 180° gives the complement. Adjusting lightness keeps the same hue family.


Four Colour Schemes That Always Work

1. Monochromatic

One hue, varying lightness and saturation. Safe, cohesive, easy to execute.

Base:    hsl(214, 80%, 51%)
Light:   hsl(214, 80%, 85%)   ← backgrounds, cards
Dark:    hsl(214, 80%, 25%)   ← text, borders
Muted:   hsl(214, 30%, 60%)   ← secondary text

2. Analogous

Three hues adjacent on the colour wheel (within ±30–60°). Natural, calm, harmonious.

Primary:   hsl(214, 80%, 51%)   blue
Secondary: hsl(184, 70%, 45%)   teal
Accent:    hsl(244, 70%, 55%)   violet

3. Complementary

Two hues 180° apart. High contrast, energetic. Use the complement sparingly as an accent.

Primary: hsl(214, 80%, 51%)   blue
Accent:  hsl(34,  80%, 55%)   orange  ← 214 + 180 = 394 mod 360 = 34

4. Triadic

Three hues 120° apart. Balanced, vibrant. Keep two muted and one as the accent.

Hue 1: hsl(214, 80%, 51%)   blue
Hue 2: hsl(334, 70%, 52%)   pink    ← 214 + 120
Hue 3: hsl(94,  65%, 40%)   green   ← 214 + 240

The 60-30-10 Rule

Regardless of which scheme you choose, distribute colours in this ratio:

  • 60% — dominant (neutral background, white, light grey)
  • 30% — secondary (cards, sidebars, headers — your brand colour)
  • 10% — accent (CTAs, links, highlights — the complement)

This mirrors interior design principles and prevents any single colour from overwhelming the layout.


WCAG Contrast Ratios (Mandatory)

The Web Content Accessibility Guidelines (WCAG) define minimum contrast between text and background. These are legal requirements in the EU, UK, US (ADA), Canada, and Australia.

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

The contrast ratio formula uses relative luminance (L):

L = 0.2126 × R_lin + 0.7152 × G_lin + 0.0722 × B_lin

where R_lin = (R/255)^2.2  (simplified gamma)

contrast = (L_lighter + 0.05) / (L_darker + 0.05)

Practical rule: dark text on white (#000 on #fff) is 21:1. Mid-grey #767676 on white is exactly 4.5:1. Never use light grey text on white backgrounds.


Code Examples

JavaScript — HSL to HEX conversion

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

// Generate a complementary pair
function complementaryPair(h, s, l) {
  return {
    primary: hslToHex(h, s, l),
    complement: hslToHex((h + 180) % 360, s, l),
  };
}

console.log(complementaryPair(214, 80, 51));
// { primary: '#1a72e7', complement: '#e7831a' }

// WCAG contrast ratio
function relativeLuminance([r, g, b]) {
  return [r, g, b]
    .map((c) => c / 255)
    .map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
    .reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0);
}

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

console.log(contrastRatio([26, 115, 232], [255, 255, 255]).toFixed(2)); // 3.07 (fails AA)
console.log(contrastRatio([0, 0, 0], [255, 255, 255]).toFixed(2));      // 21.00 (passes AAA)

Python

def hsl_to_rgb(h: float, s: float, l: float) -> tuple[int, int, int]:
    """h 0-360, s 0-100, l 0-100 → r,g,b 0-255"""
    import colorsys
    r, g, b = colorsys.hls_to_rgb(h / 360, l / 100, s / 100)
    return round(r * 255), round(g * 255), round(b * 255)

def analogous_scheme(h: float, s: float = 70, l: float = 50, step: int = 30):
    return [hsl_to_rgb((h + i * step) % 360, s, l) for i in range(-1, 2)]

print(analogous_scheme(214))
# [(110, 188, 185), (51, 135, 228), (110, 85, 228)]

def relative_luminance(r: int, g: int, b: int) -> float:
    def linearise(c):
        c /= 255
        return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
    return 0.2126 * linearise(r) + 0.7152 * linearise(g) + 0.0722 * linearise(b)

def contrast_ratio(rgb1, rgb2) -> float:
    l1 = relative_luminance(*rgb1)
    l2 = relative_luminance(*rgb2)
    lighter, darker = max(l1, l2), min(l1, l2)
    return (lighter + 0.05) / (darker + 0.05)

print(f"{contrast_ratio((0,0,0),(255,255,255)):.1f}:1")  # 21.0:1

Go

package main

import (
    "fmt"
    "math"
)

func hslToRGB(h, s, l float64) (r, g, b uint8) {
    s /= 100; l /= 100
    c := (1 - math.Abs(2*l-1)) * s
    x := c * (1 - math.Abs(math.Mod(h/60, 2)-1))
    m := l - c/2
    var rf, gf, bf float64
    switch {
    case h < 60:  rf, gf, bf = c, x, 0
    case h < 120: rf, gf, bf = x, c, 0
    case h < 180: rf, gf, bf = 0, c, x
    case h < 240: rf, gf, bf = 0, x, c
    case h < 300: rf, gf, bf = x, 0, c
    default:      rf, gf, bf = c, 0, x
    }
    return uint8((rf+m)*255), uint8((gf+m)*255), uint8((bf+m)*255)
}

func complementary(h, s, l float64) (string, string) {
    r1, g1, b1 := hslToRGB(h, s, l)
    r2, g2, b2 := hslToRGB(math.Mod(h+180, 360), s, l)
    return fmt.Sprintf("#%02x%02x%02x", r1, g1, b1),
           fmt.Sprintf("#%02x%02x%02x", r2, g2, b2)
}

func main() {
    primary, complement := complementary(214, 80, 51)
    fmt.Println(primary, complement)   // #1a72e7 #e7831a
}

PHP

<?php
function hslToHex(float $h, float $s, float $l): string {
    $s /= 100; $l /= 100;
    $c = (1 - abs(2 * $l - 1)) * $s;
    $x = $c * (1 - abs(fmod($h / 60, 2) - 1));
    $m = $l - $c / 2;
    [$r, $g, $b] = match(true) {
        $h < 60  => [$c, $x, 0],
        $h < 120 => [$x, $c, 0],
        $h < 180 => [0, $c, $x],
        $h < 240 => [0, $x, $c],
        $h < 300 => [$x, 0, $c],
        default  => [$c, 0, $x],
    };
    return sprintf('#%02x%02x%02x',
        round(($r + $m) * 255),
        round(($g + $m) * 255),
        round(($b + $m) * 255));
}

// Triadic scheme
function triadicScheme(float $h, float $s = 70, float $l = 50): array {
    return [
        hslToHex($h, $s, $l),
        hslToHex(fmod($h + 120, 360), $s, $l),
        hslToHex(fmod($h + 240, 360), $s, $l),
    ];
}

print_r(triadicScheme(214));
// #1a72e7, #e71a72, #72e71a
?>

Quick Reference

Scheme Hue offsets Character Use for
Monochromatic 0° only Safe, calm Corporate, minimal
Analogous ±30–60° Natural, warm Lifestyle, food, nature
Complementary +180° Bold, energetic CTAs, sport, sale
Split-complementary +150°, +210° Vibrant but balanced Creative, youth brands
Triadic +120°, +240° Playful, diverse Games, education, children

Common Pitfalls

Using too many hues. Three is the maximum for most projects. More than three and the palette looks accidental.

Ignoring WCAG contrast. #999999 on #ffffff has a contrast ratio of 2.85:1 — it fails AA for all text sizes. Many popular "light grey" colour choices are inaccessible.

Picking colours in full-saturation. Pure hsl(214, 100%, 50%) is harsh and looks cheap. Reduce saturation to 60–80% and adjust lightness for a more polished result.

Not testing on dark backgrounds. A colour that looks fine on white can vanish on dark mode. Always test your palette at L=10% background and L=90% background.

Conflating "brand colour" with "primary UI colour". A logo colour designed for print does not need to appear at full saturation across 60% of your UI. Use it at reduced opacity or lightness for backgrounds.

Forgetting colour-blind users. ~8% of males have some form of colour vision deficiency. Never use colour as the only way to convey meaning — pair it with an icon, label, or pattern.


Frequently Asked Questions

How many colours should a website palette have?
Three to five: one neutral (white/light grey), one brand primary, one accent, and optionally a neutral dark (near-black) and one error/success semantic colour. Anything beyond five adds complexity without benefit.

Should I use HEX, RGB, or HSL in CSS?
HSL in design work (it is easier to reason about), HEX in design tokens and shared config (it is the lingua franca between tools), and RGB/RGBA when you need alpha channels in CSS (or use HSL with / alpha in modern CSS: hsl(214 80% 51% / 0.15)).

What is the easiest way to find a colour's complement?
In HSL: add 180 to the hue, mod 360. hsl(214, S, L)hsl(34, S, L).

My brand colour fails WCAG AA. What should I do?
Darken the colour (lower lightness) until it passes, or use it as a background and put white text on it — then check that the white-on-brand contrast passes instead. Most brands adapt a darker shade of their primary for UI use.

What contrast ratio do I need for icons and non-text elements?
WCAG 2.1 SC 1.4.11 requires 3:1 for UI components and graphical objects (icons, chart elements, focus indicators).

How do I generate a full design-ready palette from a single colour?
Start from your base HSL, then generate a tonal scale: keep hue and saturation fixed, vary lightness in steps of 10–15% from ~10% (darkest) to ~95% (lightest). This gives you 6–8 tokens (50/100/200/300/400/500/600/700 in Tailwind notation) without any hue math.

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools