Color Theory for Web Designers
The difference between a design that looks professional and one that looks amateur often comes down to color. Here's the theory you need, from how digital color works to accessibility requirements to building a palette that actually holds together.
Color Models: RGB, HSL, CMYK, HEX
Color is a perception — your brain's interpretation of light hitting your retina. Digital color systems are models that approximate this experience. The model you use depends on the medium.
Screens emit light, combining it to create colors (additive color mixing). Paint and ink absorb light, subtracting it (subtractive color mixing). These two physics lead to fundamentally different color models.
| Model | Type | Used by | Range |
|---|---|---|---|
| RGB | Additive | Screens, web, photos | 0–255 per channel |
| HEX | Additive (RGB) | Web/CSS | 00–FF per channel |
| HSL / HSB | Additive (derived) | Design tools, CSS | 0–360°, 0–100%, 0–100% |
| CMYK | Subtractive | Print, offset press | 0–100% per channel |
As a web designer, you'll live in RGB, HEX, and HSL. CMYK becomes relevant if you're designing assets that will also be printed — the conversion from RGB to CMYK can shift colors noticeably, and some vivid screen colors (especially saturated blues and greens) simply can't be reproduced in CMYK print.
HEX: The Web Standard
HEX is just RGB expressed in base-16 (hexadecimal). Each pair of hex digits maps to one RGB channel: red, green, blue. Digits go from 0–9 then A–F, so the range is 00 (decimal 0) to FF (decimal 255).
#RRGGBB#FF0000 = rgb(255, 0, 0) = pure red #00FF00 = rgb(0, 255, 0) = pure green #0000FF = rgb(0, 0, 255) = pure blue #FFFFFF = rgb(255,255,255) = white #000000 = rgb(0, 0, 0) = black #808080 = rgb(128,128,128) = medium gray
Shorthand — only when both digits in each pair match: #F00 = #FF0000 (red) #ABC = #AABBCC
With Alpha Transparency
<code">#RRGGBBAA (8-digit hex — CSS Color Level 4)#FF0000FF = fully opaque red #FF000080 = 50% transparent red (80 hex = 128 decimal ≈ 50%) #FF000000 = fully transparent red
Manual HEX to RGB Conversion
<code">#4A90E2Split: 4A | 90 | E2 Convert each hex pair to decimal: 4A = 4×16 + 10 = 74 90 = 9×16 + 0 = 144 E2 = 14×16 + 2 = 226
Result: rgb(74, 144, 226)
Do this instantly with our HEX to RGB Converter.
RGB and When to Use It
RGB expresses color as three values (red, green, blue) each from 0 to 255. It maps directly to how monitors work — each pixel has red, green, and blue subpixels lit at the specified intensities.
<code">rgb(255, 87, 51) /* A warm orange / rgba(255, 87, 51, 0.5) / Same, 50% transparent */
/* Modern CSS syntax (no commas) — CSS Color Level 4 */ rgb(255 87 51) rgb(255 87 51 / 50%) rgb(255 87 51 / 0.5)
RGB is most useful when you're working with color values programmatically — JavaScript animations, canvas drawing, image processing. It's less intuitive for choosing colors by hand, because the relationship between the three numbers and the resulting color isn't obvious. Adjusting brightness requires changing all three values in proportion.
<code">// JavaScript: animate a color transition
function interpolateRGB(from, to, t) {
return {
r: Math.round(from.r + (to.r - from.r) * t),
g: Math.round(from.g + (to.g - from.g) * t),
b: Math.round(from.b + (to.b - from.b) * t),
};
}// From red to blue over time const color = interpolateRGB({r:255,g:0,b:0}, {r:0,g:0,b:255}, 0.5); // {r: 128, g: 0, b: 128} — purple at midpoint
HSL: The Designer's Format
HSL (Hue, Saturation, Lightness) represents color the way humans think about it:
- Hue (0°–360°): The color itself — where you are on the color wheel
- Saturation (0%–100%): How vivid the color is. 0% is gray, 100% is fully saturated
- Lightness (0%–100%): How light or dark. 0% is black, 100% is white, 50% is the "pure" color
<code">hsl(0, 100%, 50%) /* Red / hsl(120, 100%, 50%) / Green / hsl(240, 100%, 50%) / Blue / hsl(0, 0%, 50%) / Gray (any hue, 0 saturation) / hsl(0, 0%, 100%) / White / hsl(0, 0%, 0%) / Black */
hsla(220, 80%, 50%, 0.7) /* Blue, 70% opaque */
/* Modern CSS (no commas) */ hsl(220 80% 50%) hsl(220 80% 50% / 70%)
Hue Map (Color Wheel Positions)
<code"> 0° / 360° = Red 30° = Orange 60° = Yellow 120° = Green 180° = Cyan 210° = Sky blue 240° = Blue 270° = Purple / Violet 300° = Magenta 330° = Pink
The power of HSL for design work: you can create a full set of color variants by changing just one value.
<code">/* A blue with all its variants — same hue, different S/L / hsl(220, 80%, 20%) / dark navy / hsl(220, 80%, 35%) / darker blue / hsl(220, 80%, 50%) / pure blue (brand color) / hsl(220, 80%, 65%) / light blue / hsl(220, 80%, 80%) / pale blue / hsl(220, 40%, 50%) / muted/desaturated blue / hsl(220, 20%, 90%) / near-white blue tint */
That's a complete single-color palette with no guesswork. With RGB, you'd have to calculate all those by trial and error.
CMYK: Screen vs Print
CMYK (Cyan, Magenta, Yellow, Key/Black) is the ink model used in printing. Mix all four inks at maximum and you get black (theoretically — in practice, printers add the K channel for a truer black). Mix none and you get white (the paper).
CSS has no native CMYK support — browsers don't use it. CMYK matters when your web assets also go to print: logos, marketing materials, product packaging. The RGB-to-CMYK conversion can shift colors, particularly:
- Vivid greens (RGB can hit 0,255,0 — no equivalent CMYK)
- Electric blues (impossible in CMYK gamut)
- Bright oranges (partially out of gamut)
If your brand color is specified in CMYK for print, get the closest-matching RGB value and use that for screen. Don't auto-convert — the math rarely produces a visually equivalent result. A human designer should sign off on the screen-to-print translation.
Convert between formats with our CMYK to HEX Converter.
Color Wheel and Harmony Rules
The color wheel is a circular arrangement of hues. Color harmony rules describe which positions on the wheel produce pleasing combinations. These aren't laws — they're starting points. Great design breaks them intentionally; bad design breaks them accidentally.
Blue + Orange. High contrast, energetic. Use sparingly — too much complementary is visually jarring. Best for CTAs against a single dominant color background.
Cyan + Blue + Purple. Harmonious and calm. Works well for monochromatic-ish designs that still have some variation. Nature uses analogous colors constantly.
Red + Green + Blue (the primary triad). Vibrant and balanced. Let one color dominate (60%), use the others as secondary (30%) and accent (10%).
Base + two colors flanking its complement. Strong contrast but less tension than pure complementary. A safer choice when complementary feels too aggressive.
Generating Harmonies with HSL
<code">/* Given a base hue of 220° (blue) *//* Complementary: rotate 180° / hsl(220, 80%, 50%) / base / hsl(40, 80%, 50%) / complement (220 + 180 = 400 → 40°) */
/* Analogous: ±30° */ hsl(190, 70%, 50%) hsl(220, 80%, 50%) hsl(250, 70%, 50%)
/* Triadic: +120° each / hsl(220, 80%, 50%) / base / hsl(340, 80%, 50%) / 220 + 120 = 340° (pink) / hsl(100, 80%, 50%) / 220 + 240 = 460 → 100° (yellow-green) */
Generate and visualize color harmonies with our Color Picker.
Contrast Ratios and WCAG
Contrast ratio is the ratio of luminance between two colors. It ranges from 1:1 (identical colors) to 21:1 (black on white). The Web Content Accessibility Guidelines (WCAG) define minimum requirements.
| WCAG Level | Normal text (<18pt) | Large text (≥18pt bold) | UI components |
|---|---|---|---|
| AA (minimum) | 4.5:1 | 3:1 | 3:1 |
| AAA (enhanced) | 7:1 | 4.5:1 | — |
AA is the legal minimum in most jurisdictions that reference WCAG. AAA is aspirational — worth targeting for body text on sites where accessibility is critical (government, healthcare, financial).
How Contrast Is Calculated
<code">// Relative luminance formula (WCAG 2.x)
function relativeLuminance(r, g, b) {
const toLinear = (c) => {
c = c / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
function contrastRatio(rgb1, rgb2) {
const L1 = relativeLuminance(...rgb1);
const L2 = relativeLuminance(...rgb2);
const lighter = Math.max(L1, L2);
const darker = Math.min(L1, L2);
return (lighter + 0.05) / (darker + 0.05);
}
// Example: navy text on white background
contrastRatio([0, 31, 63], [255, 255, 255])
// Returns: ~14.7 — well above 4.5:1 AA requirement
Check your color combinations with our Contrast Checker, which shows WCAG compliance at AA and AAA levels.
Common Contrast Failures
- Light gray text on white background — design trend that fails accessibility
- Colored text on colored background — hard to predict without checking
- Ghost buttons — white/light border text on white background
- Disabled state indicators — often too low contrast by default
- Placeholder text — browsers default to 40% opacity, which often fails
Color Blindness Considerations
Approximately 8% of men and 0.5% of women have some form of color vision deficiency. Designing for them isn't charity — it's reaching 300+ million people globally.
Types of Color Blindness
| Type | Deficiency | Prevalence (men) | What's confused |
|---|---|---|---|
| Deuteranomaly | Reduced green sensitivity | ~5% | Red / green |
| Protanomaly | Reduced red sensitivity | ~1% | Red / green (red appears darker) |
| Deuteranopia | No green cones | ~1% | Red / green (severe) |
| Protanopia | No red cones | ~1% | Red / green (severe) |
| Tritanopia | No blue cones | <0.01% | Blue / yellow |
| Achromatopsia | No color vision | 0.003% | Everything (grayscale only) |
Red-green confusion is by far the most common — nearly all color blindness falls into this category. This has direct design implications.
Design Rules
Never use color as the only signal. "Errors are shown in red" fails for red-green color blindness. Add an icon (✕), a text label ("Error:"), a different shape, or a border. Color should be a secondary signal, not the primary one.
Avoid red/green combinations. Red on green or green on red — even if the contrast ratio technically passes — can be impossible to read for deuteranopes. Use red on white or green on dark for status indicators.
Test with a simulator. Browser extensions like Colorblindly let you see your design as someone with each type of color blindness would see it. It's a quick and humbling exercise.
The safe palette: Blue and orange are the most reliably distinguishable across all forms of color blindness. Use blue for one state and orange (or yellow) for another when you need two distinct colors to carry meaning.
<code">/* Don't do this — color only */
.status-success { color: green; }
.status-error { color: red; }/* Do this — color + icon + text / .status-success::before { content: "✓ "; } .status-error::before { content: "✕ "; } .status-success { color: hsl(145, 60%, 35%); } / Dark green — good contrast / .status-error { color: hsl(0, 70%, 45%); } / Dark red — good contrast */
Dark Mode Design
Dark mode is now a baseline expectation. Users who prefer it are passionate about it, and implementing it badly (white-on-black text that glows, oversaturated accent colors) is worse than not doing it.
Dark Mode Mistakes
- Pure black backgrounds (#000000): Creates extreme contrast that causes eye strain and halation on OLED screens. Use very dark gray like
hsl(220, 10%, 10%)instead. - Same saturated colors in dark mode: Bright blues and reds that look fine on white look aggressive on dark backgrounds. Reduce saturation by 10–20% or increase lightness slightly.
- Inverting light mode colors: Just flipping light/dark produces ugly, high-contrast results. Redesign for dark, don't invert.
- Hard shadows on dark: Box shadows invisible. Use glows (matching the element color) or elevation via lighter backgrounds instead.
CSS Implementation
<code">/* System preference /
@media (prefers-color-scheme: dark) {
:root {
--background: hsl(220, 15%, 10%);
--surface: hsl(220, 12%, 15%);
--surface-raised: hsl(220, 10%, 20%);
--text-primary: hsl(220, 10%, 95%);
--text-secondary: hsl(220, 8%, 65%);
--accent: hsl(220, 70%, 65%); / lighter in dark mode */
--border: hsl(220, 15%, 25%);
}
}
/* Manual toggle — add class to root /
:root[data-theme="dark"] {
/ same variables */
}
/* Both system + manual /
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
/ dark mode variables */
}
}
Surface Hierarchy in Dark Mode
In light mode, elevation is expressed with shadows. In dark mode, lighter colors indicate higher elevation (closer to the "light source" above). This is Material Design's approach and it works well.
<code">/* Dark mode elevation via lightness / --surface-level-0: hsl(220, 15%, 8%); / background / --surface-level-1: hsl(220, 13%, 12%); / cards / --surface-level-2: hsl(220, 11%, 16%); / modals, popovers / --surface-level-3: hsl(220, 9%, 20%); / tooltips, dropdowns */
Building a Palette
A production-ready palette needs more than a few brand colors. You need variants for all states — hover, active, disabled, success, warning, error — across both light and dark modes.
Start with Your Brand Color in HSL
If you're given a HEX brand color, convert it to HSL and use the hue as your anchor. Then build a scale.
<code">/* Brand color: #3b82f6 = hsl(217, 91%, 60%) */
/* Neutral scale (your main grays) */ --gray-50: hsl(220, 14%, 96%); --gray-100: hsl(220, 13%, 91%); --gray-200: hsl(220, 11%, 82%); --gray-300: hsl(216, 12%, 70%); --gray-400: hsl(218, 11%, 58%); --gray-500: hsl(220, 9%, 46%); --gray-600: hsl(215, 14%, 34%); --gray-700: hsl(217, 19%, 27%); --gray-800: hsl(215, 28%, 17%); --gray-900: hsl(221, 39%, 11%);
/* Brand (primary) scale / --blue-50: hsl(214, 100%, 97%); --blue-100: hsl(214, 95%, 93%); --blue-200: hsl(213, 97%, 87%); --blue-300: hsl(212, 96%, 78%); --blue-400: hsl(213, 94%, 68%); --blue-500: hsl(217, 91%, 60%); / brand color */ --blue-600: hsl(221, 83%, 53%); --blue-700: hsl(224, 76%, 48%); --blue-800: hsl(226, 71%, 40%); --blue-900: hsl(224, 64%, 33%);
/* Semantic colors */ --success: hsl(142, 71%, 45%); --warning: hsl(38, 92%, 50%); --error: hsl(0, 84%, 60%);
The 60-30-10 Rule
Use your palette with intention: 60% dominant (typically neutrals — backgrounds, large surfaces), 30% secondary (brand color for key UI elements), 10% accent (CTAs, highlights, notifications). More variety than this creates visual noise.
CSS Custom Properties for Theming
CSS custom properties (variables) are the right tool for color theming. Define your palette once, reference it everywhere, swap themes by changing the root variables.
<code">/* Semantic layer — what the color does, not what it is */
:root {
--color-background: hsl(220, 14%, 96%);
--color-surface: hsl(0, 0%, 100%);
--color-surface-raised: hsl(220, 14%, 92%);
--color-text-primary: hsl(220, 30%, 15%);
--color-text-secondary: hsl(220, 15%, 40%);
--color-text-disabled: hsl(220, 10%, 65%);
--color-border: hsl(220, 13%, 84%);
--color-primary: hsl(217, 91%, 60%);
--color-primary-hover: hsl(221, 83%, 53%);
--color-primary-text: hsl(0, 0%, 100%);
--color-success: hsl(142, 71%, 35%);
--color-warning: hsl(38, 92%, 45%);
--color-error: hsl(0, 84%, 50%);
}@media (prefers-color-scheme: dark) { :root { --color-background: hsl(220, 15%, 10%); --color-surface: hsl(220, 12%, 15%); --color-surface-raised: hsl(220, 10%, 20%); --color-text-primary: hsl(220, 10%, 93%); --color-text-secondary: hsl(220, 8%, 65%); --color-text-disabled: hsl(220, 6%, 40%); --color-border: hsl(220, 15%, 25%); --color-primary: hsl(217, 91%, 68%); /* lighter in dark mode */ --color-primary-hover: hsl(217, 91%, 75%); --color-primary-text: hsl(220, 15%, 10%); --color-success: hsl(142, 60%, 55%); --color-warning: hsl(38, 90%, 60%); --color-error: hsl(0, 80%, 65%); } }
/* Usage */ .btn-primary { background: var(--color-primary); color: var(--color-primary-text); border: 1px solid var(--color-primary); } .btn-primary:hover { background: var(--color-primary-hover); }
Modern CSS Color Functions
CSS Color Level 4 and 5 introduce functions that let you work with colors programmatically in CSS — no JavaScript required.
color-mix()
<code">/* Mix two colors in a given color space */ color: color-mix(in oklch, blue 70%, white); color: color-mix(in srgb, #3b82f6 50%, transparent);/* Useful for hover states */ .btn:hover { background: color-mix(in srgb, var(--color-primary) 85%, black); }
Relative Colors (CSS Color Level 5)
<code">/* Generate variants from a base color */
:root { --brand: hsl(217, 91%, 60%); }
.lighter { color: hsl(from var(--brand) h s calc(l + 15%)); }
.darker { color: hsl(from var(--brand) h s calc(l - 15%)); }
.muted { color: hsl(from var(--brand) h calc(s - 30%) l); }
/* Shift hue for a complementary color */
.complement { color: hsl(from var(--brand) calc(h + 180) s l); }
oklch and oklab — Perceptually Uniform Color
HSL's lightness value is not perceptually uniform — a yellow at L:50% looks much lighter than a blue at L:50%. The oklch and oklab color spaces are perceptually uniform, meaning equal changes in values produce equal changes in perceived lightness.
<code">/* oklch(lightness chroma hue) / color: oklch(0.6 0.2 250); / A medium blue */
/* Generating a palette with consistent perceived lightness / --step-1: oklch(0.95 0.02 250); --step-2: oklch(0.85 0.06 250); --step-3: oklch(0.70 0.12 250); --step-4: oklch(0.55 0.18 250); / base */ --step-5: oklch(0.40 0.18 250); --step-6: oklch(0.25 0.14 250);
Browser support for oklch is now excellent (96%+ of browsers as of 2026). It's worth using for new projects, especially when you need consistent perceptual lightness across different hues.
Tools
Contrast Checker
Check foreground/background combinations against WCAG AA and AAA requirements.
Check ContrastColor Picker
Visual color picker with complementary, triadic, and analogous scheme generation.
Open Color Picker