Color Theory for Developers: Stop Guessing Hex Codes
Most developers pick colors by feel. Here's how to use HSL, WCAG contrast ratios, and color harmony rules to build palettes that are both beautiful and accessible.
You've been there. You need a button color. You open a color picker, click around until something "looks nice," copy the hex code, and move on. Two weeks later your designer asks why the call-to-action button is invisible to 8% of your male users.
Color isn't decoration. It's a functional layer of your UI. Getting it wrong creates accessibility lawsuits, usability failures, and brand inconsistency. Getting it right is surprisingly systematic.
1. RGB is for Machines, HSL is for Humans
Hex codes like #1a8f4e are RGB values crammed into a compact notation. They tell the screen
how much Red, Green, and Blue light to mix. But they tell you nothing. Can you look at
#1a8f4e and predict whether it's dark or light? Warm or cool?
HSL (Hue, Saturation, Lightness) solves this by mapping color to how humans actually perceive it:
- Hue (0-360): The color wheel position. 0 = red, 120 = green, 240 = blue.
- Saturation (0-100%): How vivid vs. gray. 0% = grayscale, 100% = pure color.
- Lightness (0-100%): How bright. 0% = black, 50% = pure color, 100% = white.
With HSL, creating color variations is trivial. Need a hover state? Decrease lightness by 10%. Need a subtle background? Keep the same hue and saturation, bump lightness to 95%. Need a disabled state? Drop saturation to 20%. This is impossible to reason about in hex.
HSL in CSS
:root {
--brand: hsl(160, 80%, 50%);
--brand-hover: hsl(160, 80%, 40%);
--brand-light: hsl(160, 80%, 95%);
--brand-muted: hsl(160, 20%, 50%);
}
Notice how the hue stays at 160 across all variants. This guarantees they all "belong" to the same color family. Try doing that with hex codes.
2. WCAG Contrast: The Numbers That Matter
The Web Content Accessibility Guidelines (WCAG) define minimum contrast ratios between text and its background. These aren't suggestions; in many jurisdictions they're legal requirements.
- AA Normal Text: 4.5:1 ratio minimum.
- AA Large Text (18px+ bold or 24px+): 3:1 ratio minimum.
- AAA (Enhanced): 7:1 for normal text, 4.5:1 for large text.
The most common failure? Gray text on a white background. That elegant
#999 on #fff you're using for placeholder text? It has a contrast ratio of
2.85:1. It fails every WCAG level. The fix is simple: darken it to at least #767676
(4.54:1).
How Contrast Ratios Are Calculated
The formula uses relative luminance, which accounts for how the human eye perceives different wavelengths of light. Green light appears brighter than blue light at the same intensity. The ratio compares the luminance of the lighter color to the darker one:
contrast = (L1 + 0.05) / (L2 + 0.05)
// L1 = lighter color luminance
// L2 = darker color luminance
// Result ranges from 1:1 (identical) to 21:1 (black on white)
You don't need to calculate this by hand. Use a tool that shows the ratio in real-time as you pick colors.
3. Color Harmony: Why Some Palettes "Work"
Professional color palettes aren't random. They follow mathematical relationships on the color wheel:
- Complementary: Two colors opposite each other (180° apart). High contrast, high energy. Think blue and orange sports branding.
- Analogous: Three colors adjacent on the wheel (within 30-60°). Harmonious and calming. Common in nature photography sites.
- Triadic: Three colors evenly spaced (120° apart). Balanced and vibrant. Used when you need a primary, secondary, and accent color.
- Split-Complementary: One base color plus two colors adjacent to its complement. Easier to work with than pure complementary while maintaining visual tension.
When building a UI palette, start with your brand's primary hue. Then use a harmony rule to derive your secondary and accent colors. Add neutrals (desaturated versions of your primary hue) for backgrounds, borders, and text.
4. Colorblindness: Designing for 300 Million People
About 8% of men and 0.5% of women have some form of color vision deficiency. The most common type, deuteranopia (red-green colorblindness), makes your red error states and green success states look identical.
The fix isn't to avoid color—it's to never use color as the only way to convey information:
- Error fields: red border plus an icon plus error text.
- Status badges: color plus a label ("Active," "Paused," "Failed").
- Charts: color plus patterns (stripes, dots) or direct labels.
Test your designs by simulating protanopia, deuteranopia, and tritanopia. If the UI is still usable in grayscale, you've done it right.
5. Dark Mode: More Than Inverting Colors
Dark mode isn't filter: invert(1). Inverting creates harsh whites, wrecks images, and
produces backgrounds that are too dark to read comfortably.
Proper dark mode design follows different rules:
- Background: Use dark grays (#121212 to #1e1e1e), not pure black (#000). Pure black causes "halation"—text appears to bleed and vibrate against the background, especially on OLED screens.
- Elevation = Lightness: In Material Design, surfaces that are "closer" to the user get lighter backgrounds. A modal overlay is lighter than the page behind it.
- Reduce saturation: Fully saturated colors on dark backgrounds cause eye strain. Reduce saturation by 10-20% for dark mode variants.
- Contrast still matters: WCAG ratios apply in dark mode too. Light gray text on a dark gray background fails more often than you'd think.
6. Color Spaces Beyond RGB: HSL, oklch, and When to Use Each
RGB is the native language of screens — each pixel is a mix of red, green, and blue light emitted at varying intensity. It is the right model for the hardware layer. For design and programming, it is one of the worst models for human thought.
Consider: how do you make a color "10% lighter" in RGB? There is no intuitive answer — you would need to adjust R, G, and B in a coordinated way that preserves the hue. In HSL (Hue, Saturation, Lightness), it is trivial: increase L by 10.
- HSL (hsl()): Hue as a degree on the color wheel (0-360), Saturation as percentage, Lightness as percentage. Intuitive for generating tints and shades. Supported in all browsers since IE9.
- oklch (oklch()): A perceptually uniform color space introduced in CSS Color Level 4. "Perceptually uniform" means that a 10% change in lightness looks like the same amount of change regardless of the hue. HSL is not perceptually uniform — yellow at 50% lightness looks much brighter than blue at 50% lightness. oklch fixes this.
- P3 (color(display-p3 ...)): Accesses the wider P3 colour gamut available on Apple devices, newer Android screens, and most modern monitors. Colours outside the sRGB gamut — vivid greens, bright reds — are expressible here.
/* Same blue, three color spaces */
.button {
/* Standard — sRGB, works everywhere */
background: hsl(220, 90%, 56%);
/* Modern — perceptually uniform, CSS Color 4 */
background: oklch(60% 0.2 250);
/* Wide gamut — richer on P3 displays, fallback to sRGB */
background: color(display-p3 0.1 0.4 0.9);
}
For new design systems targeting modern browsers, oklch is the recommended approach — it produces more consistent palettes and makes programmatic color manipulation predictable.
7. Generating Palettes Programmatically
Design systems often need a full palette from a single brand colour: primary, hover state, disabled state, background tints, text on coloured surfaces. HSL makes this straightforward:
// Generate a 9-step tint/shade scale from a base HSL colour
function generateScale(hue, saturation) {
const lightnesses = [95, 85, 75, 65, 55, 45, 35, 25, 15];
return lightnesses.map(l => `hsl(${hue}, ${saturation}%, ${l}%)`);
}
// Brand blue: hue 220, saturation 85%
const blueScale = generateScale(220, 85);
// ["hsl(220, 85%, 95%)", "hsl(220, 85%, 85%)", ... "hsl(220, 85%, 15%)"]
// Use index 4 (55%) as the primary, 3 (65%) as hover, 0 (95%) as background tint
// Complementary colour: rotate hue by 180 degrees
const complementaryHue = (220 + 180) % 360; // 40 (warm orange)
const orangeScale = generateScale(40, 75);
This approach produces consistent, accessible palettes because you are controlling a single variable (lightness) while holding hue and saturation constant. The same pattern works for analogous colours (rotate hue by 30-60°), triadic (120°), and split-complementary (150°) schemes.
Frequently Asked Questions
What is the difference between HSL and HSB/HSV?
HSL (Hue, Saturation, Lightness) and HSB/HSV (Hue, Saturation, Brightness/Value) are both cylindrical representations of RGB, but they model the "brightness" axis differently. In HSL, 50% lightness is a "pure" colour (neither washed out nor dark). In HSB, 100% brightness with 100% saturation is also a pure colour, but reducing brightness moves toward black while reducing saturation moves toward white. CSS uses HSL. Many design tools (Figma, Photoshop, Sketch) use HSB/HSV. They are not interchangeable numerically — a 70% value in HSL is not the same as 70% in HSB.
How do I ensure accessible contrast automatically in a design system?
Use a contrast-checking function at build time or in your theme generator. The WCAG contrast ratio formula is documented and deterministic — you can calculate it from any two RGB values. Many design system tools (Radix Colours, Tailwind CSS 3+) pre-calculate their palettes so that specific combinations are guaranteed to pass WCAG AA. For oklch-based palettes, the perceptual uniformity makes it easier to predict which lightness values will pass contrast ratios — a text colour at L=25% will reliably read on a background at L=90% in oklch, whereas in HSL you need to verify per-hue due to the non-uniformity.
Conclusion
Color in UI design is engineering, not art. Use HSL to reason about variations. Check WCAG ratios before shipping. Apply harmony rules instead of guessing. And always simulate colorblindness.
Need to build an accessible palette? Use our Color Palette Generator to create harmonious schemes with real-time WCAG contrast checking and colorblind simulation.