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.