I once spent twenty minutes trying to make a color 20% lighter. The original color was #3498DB (a medium blue). I tried adding 20% to each RGB channel: 52+51=103, 152+51=203, 219+51=255. The result, #67CBFF, was lighter but also more saturated and shifted slightly toward cyan. What I wanted was the same blue, just lighter. I was modifying the wrong color model for the job.
Different color formats exist because they describe color from different perspectives, and some perspectives make certain operations trivial that are nightmarish in others.
RGB: how screens see color
RGB (Red, Green, Blue) describes color by how much of each light primary to emit. It maps directly to hardware: every pixel on your screen is a cluster of red, green, and blue subpixels.
color: rgb(52, 152, 219); /* functional notation */
color: #3498DB; /* hex notation (same color) */
color: rgb(20.4% 59.6% 85.9%); /* percentage notation */
Hex notation is just RGB in base-16. #3498DB breaks down as: R=0x34 (52), G=0x98 (152), B=0xDB (219).
When to use RGB: when you need exact hardware-level color specification, when working with APIs that expect RGB values, or when colors are defined by brand guidelines in hex/RGB.
The problem with RGB: it has no concept of perceptual attributes. "Make this lighter" means increasing all three channels, but by how much each? "Make this less saturated" means moving all channels toward the same value, but which value? These operations require understanding the relationship between channels that RGB does not encode.
HSL: how designers think about color
HSL (Hue, Saturation, Lightness) describes color by its perceptual attributes:
- Hue: the color itself, as a position on the color wheel (0-360 degrees). 0 is red, 120 is green, 240 is blue.
- Saturation: how vivid the color is (0% is gray, 100% is fully saturated).
- Lightness: how light or dark (0% is black, 50% is the pure hue, 100% is white).
color: hsl(204, 70%, 53%); /* the same #3498DB */
Now "make it 20% lighter" is trivial: increase lightness from 53% to 73%.
color: hsl(204, 70%, 73%); /* lighter, same hue and saturation */
When to use HSL: when you need to create color variations (lighter/darker/more muted), build theme systems, or generate harmonious palettes. CSS custom properties with HSL are powerful for theming:
:root {
--brand-h: 204;
--brand-s: 70%;
--brand-l: 53%;
--brand: hsl(var(--brand-h), var(--brand-s), var(--brand-l));
--brand-light: hsl(var(--brand-h), var(--brand-s), 73%);
--brand-dark: hsl(var(--brand-h), var(--brand-s), 33%);
}
The problem with HSL: it is not perceptually uniform. Lightness 50% in yellow looks much brighter to the human eye than lightness 50% in blue, because our visual system is more sensitive to yellow-green wavelengths. Two colors with identical HSL saturation and lightness values can look dramatically different in perceived brightness and vividness.
HWB: the watercolor model
HWB (Hue, Whiteness, Blackness) is in CSS Color Level 4. It describes color by how much white and black are mixed into a pure hue.
color: hwb(204 20% 14%); /* roughly the same blue */
It maps to how painters think: start with a pure color, add white to tint it, add black to shade it. It is more intuitive than HSL for many people, but it shares the same perceptual non-uniformity.
OKLCH: the perceptually uniform future
OKLCH is the format I am most excited about. Supported in all modern browsers since 2023, it is based on the OKLAB color space, which was designed specifically for perceptual uniformity.
color: oklch(62% 0.16 250); /* roughly the same blue */
The three channels:
- L: lightness (0% is black, 100% is white) -- but this time, perceptually uniform. 50% OKLCH lightness actually looks like "medium" regardless of hue.
- C: chroma (0 is gray, higher values are more vivid). Unlike HSL saturation, chroma is perceptually consistent.
- H: hue (0-360 degrees, similar to HSL).
The game-changing property is that you can adjust lightness or chroma without affecting the perceived color relationship. Two colors with the same OKLCH lightness actually look equally bright. This makes it possible to generate palettes where every color at the "same" lightness is genuinely equally readable against a white background.
/* These all have the same perceived brightness in OKLCH */
--red: oklch(65% 0.20 25);
--green: oklch(65% 0.20 145);
--blue: oklch(65% 0.20 265);
In HSL, matching lightness values would produce a green that looks much brighter than the blue. In OKLCH, they match.
Converting between formats
Every color format can be converted to every other format, but some conversions are lossy. The RGB gamut (specifically sRGB) cannot represent all colors that OKLCH can describe. If you specify a high-chroma OKLCH color, converting to sRGB may require clamping values.
The conversion paths:
HEX <-> RGB (trivial: base-16 encoding)
RGB <-> HSL (trigonometric: involves min/max of channels)
RGB <-> OKLAB (linear algebra: matrix multiplication + cube root)
OKLAB <-> OKLCH (polar conversion: cartesian to cylindrical coordinates)
The RGB-to-HSL conversion is the most commonly implemented:
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);
const l = (max + min) / 2;
if (max === min) return [0, 0, l]; // achromatic
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return [h * 360, s * 100, l * 100];
}
Common mistakes
Using hex for color manipulation. Hex is a storage format, not a manipulation format. Convert to HSL or OKLCH, make your adjustments, then convert back.
Assuming all displays show the same colors. sRGB is the web's default color space, but wide-gamut displays (P3) can show colors outside sRGB. CSS now supports color(display-p3 1 0 0) for vivid reds that sRGB cannot represent. If you are doing color-sensitive work, test on multiple displays.
Interpolating in RGB. Transitioning between two colors in RGB space (as CSS gradients did historically) produces muddy intermediate values. A gradient from red to cyan through RGB passes through a desaturated gray-brown midpoint. The same gradient in OKLCH stays vivid throughout. Modern CSS allows background: linear-gradient(in oklch, red, cyan);.
Confusing lightness models. HSL lightness 50% is the "purest" form of a hue. OKLCH lightness 50% is "perceived medium brightness." They are not equivalent, and using the wrong mental model leads to incorrect color choices.
When I need to convert between formats, check a color across models, or find the OKLCH equivalent of a hex value from a design spec, I use the color converter at zovo.one/free-tools/color-converter.
The proliferation of color formats is not a flaw -- it reflects the fact that color is a complex phenomenon with multiple useful perspectives. Use the format that makes your specific task simple. Store in hex, manipulate in HSL or OKLCH, specify in whatever the API expects.
I'm Michael Lip. I build free developer tools at zovo.one. 350+ tools, all private, all free.
Top comments (0)