RGB vs HSL vs LAB: Color Space Conversion Math Every Developer Should Know
Pick a color in any design tool and you'll see the same color described three different ways: #FF5733, hsl(11, 100%, 60%), maybe lab(55% 55 45). These aren't just different notations — they're different models of color, each with its own geometry, math, and use cases. Using the wrong one leads to muddy gradients, broken color manipulation, and inaccessible contrast ratios.
This article covers the math behind the three color spaces developers encounter most, with worked numeric examples you can verify by hand.
RGB: The Hardware Model
RGB describes color as three light intensities: red, green, blue. Each channel is typically 8 bits (0–255), giving 16.7 million possible colors. It's called an additive model because combining full red + green + blue light produces white.
RGB matches how screens work — each pixel is literally three subpixels. But it's a terrible model for reasoning about color:
- "Make this 20% lighter" has no obvious RGB operation. Do you add 51 to each channel? That shifts hue unpredictably.
- "Find a complementary color" requires mental arithmetic across three channels.
- Equal numeric steps don't look equally different. RGB(100,100,100) → RGB(120,120,120) looks like a bigger jump than RGB(200,200,200) → RGB(220,220,220), because human vision is nonlinear.
Gamma Correction
Here's a subtlety that breaks naive RGB math: the 0–255 values in sRGB are not linear light intensities. They're gamma-encoded. A pixel value of 128 is not half the physical light of 255 — it's about 22%.
The sRGB transfer function (approximately):
linear = (srgb / 255) ^ 2.2 # encoding (simplified)
srgb = linear ^ (1/2.2) * 255 # decoding
(The actual sRGB spec uses a piecewise function, but gamma 2.2 is close enough for most work.)
This matters when you blend colors. Averaging two sRGB values directly gives a result that's too dark, because you're averaging in a nonlinear space. Correct blending converts to linear light first:
// WRONG: averaging in gamma-encoded sRGB
const wrong = (a + b) / 2;
// RIGHT: convert to linear, average, convert back
function srgbToLinear(v) {
v /= 255;
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
function linearToSrgb(v) {
const s = v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
return Math.round(s * 255);
}
const right = linearToSrgb((srgbToLinear(a) + srgbToLinear(b)) / 2);
For #808080 (128) and #FFFFFF (255): naive average gives 192 (#C0C0C0), but the physically correct midpoint is ~188. Small difference here, but it compounds across gradients.
HSL: The Human-Friendly Model
HSL remaps RGB into three intuitive dimensions:
- Hue: the color's position on the wheel (0–360°). 0° = red, 120° = green, 240° = blue.
- Saturation: color intensity (0% = gray, 100% = vivid).
- Lightness: brightness (0% = black, 100% = white, 50% = "pure" color).
HSL is a cylindrical transformation of the RGB cube. Operations that are awkward in RGB become trivial:
- Darken by 10%?
lightness -= 10 - Desaturate?
saturation = 0 - Complementary color?
hue = (hue + 180) % 360
RGB → HSL: Worked Example
Convert #FF5733 (R=255, G=87, B=51) to HSL:
Step 1: Normalize to 0–1: R=1.0, G=0.341, B=0.2
Step 2: Find max and min: max=1.0 (R), min=0.2 (B), delta = 0.8
Step 3: Lightness = (max + min) / 2 = (1.0 + 0.2) / 2 = 0.6 → 60%
Step 4: Saturation. Since L > 0.5: S = delta / (2 − max − min) = 0.8 / (2 − 1.2) = 0.8 / 0.8 = 1.0 → 100%
Step 5: Hue. Max is R, so: H = 60° × (((G − B) / delta) mod 6) = 60 × ((0.341 − 0.2) / 0.8) = 60 × 0.176 = 10.6° → ~11°
Result: hsl(11, 100%, 60%) ✓
Here's the full conversion in JavaScript:
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const delta = max - min;
const l = (max + min) / 2;
let h = 0, s = 0;
if (delta !== 0) {
s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min);
if (max === r) h = ((g - b) / delta) % 6;
else if (max === g) h = (b - r) / delta + 2;
else h = (r - g) / delta + 4;
h = Math.round(h * 60);
if (h < 0) h += 360;
}
return { h, s: +(s * 100).toFixed(1), l: +(l * 100).toFixed(1) };
}
console.log(rgbToHsl(255, 87, 51)); // { h: 11, s: 100, l: 60 }
HSL's Flaw
HSL is intuitive but not perceptually uniform. hsl(0, 100%, 50%) (pure red) and hsl(240, 100%, 50%) (pure blue) have the same lightness value, but blue looks dramatically darker to human eyes. This is why blue text on black is unreadable while yellow text is fine, despite identical HSL lightness.
LAB: The Perceptual Model
CIELAB (Lab) was designed so that equal numeric distances = equal perceived differences. It has three channels:
- L*: perceptual lightness (0 = black, 100 = white)
- a*: green–red axis (negative = green, positive = red)
- b*: blue–yellow axis (negative = blue, positive = yellow)
Unlike HSL's "lightness," Lab's L* accounts for human vision's nonlinear response. A Lab gradient from black to white looks like smooth, even steps. An RGB gradient of the same looks like it jumps in the middle.
Delta E: Measuring Color Difference
Because Lab is perceptually uniform, you can compute meaningful color differences with Euclidean distance:
ΔE = √((L1−L2)² + (a1−a2)² + (b1−b2)²)
Rough interpretation:
- ΔE < 1: imperceptible to most humans
- ΔE 1–2: perceptible on close inspection
- ΔE > 5: clearly different colors
This is how design systems check contrast, how print shops verify brand colors, and how image compression algorithms decide what's "close enough."
OKLCH: Lab for CSS
CSS Color Level 4 brings lab() and oklch() to browsers. OKLCH is Lab's successor — even more perceptually uniform, with a cylindrical form (like HSL but accurate):
/* Old: unpredictable results */
.button { background: hsl(210, 100%, 50%); }
/* New: perceptually uniform */
.button { background: oklch(0.6 0.15 250); }
In OKLCH, changing lightness while holding chroma and hue constant produces colors that actually look equally vivid — something HSL fundamentally can't do.
When to Use Each
| Task | Best space | Why |
|---|---|---|
| Screen display, image data | RGB/sRGB | That's what hardware uses |
| "Darken by 10%", hue rotation | HSL | Intuitive operations |
| Gradients that look smooth | OKLCH / Lab | Perceptual uniformity |
| Measuring color difference | Lab (ΔE) | Equal distances = equal perception |
| Accessibility contrast checks | Lab L* or WCAG formula | Accounts for human vision |
| Color blending/compositing | Linear RGB | Physically correct light math |
Try It
Working with colors means constantly converting between formats — hex to RGB to HSL and back. DailyToolbox's free color picker runs entirely in your browser: pick a color visually and get instant HEX, RGB, HSL, and CMYK values, no signup needed. For building palettes, the color palette generator creates harmonious schemes from a single base color.
FAQ
Why do my CSS gradients look muddy in the middle?
You're interpolating in sRGB, which is gamma-encoded and not perceptually uniform. The midpoint of a red-to-blue gradient in sRGB passes through a muddy purple-gray. Interpolating in OKLCH or linear RGB produces a gradient where each step looks equally different. Modern CSS color-mix() in OKLCH space fixes this.
What's the difference between HSL and HSV/HSB?
Both are cylindrical RGB remappings, but they define their third axis differently. HSL's L (lightness) goes black → pure color → white. HSV's V (value/brightness) goes black → pure color, with no "past full color" concept. HSL is generally better for UI work because 50% lightness reliably gives you the "normal" version of a color.
Should I use Lab or OKLCH for new projects?
OKLCH. It's newer, more perceptually uniform than CIELAB (especially in blues), and natively supported in modern CSS. Lab is still relevant for print workflows and existing design tools, but for web work, oklch() is the future.
How do I convert between color spaces without a library?
For RGB↔HSL, the formulas in this article are sufficient (about 20 lines of code). For Lab/OKLCH, the math involves XYZ intermediate space and is error-prone to hand-roll — use a tested library or the browser's built-in CSS color parsing. Don't implement CIELAB from a Stack Overflow snippet without testing against known reference values.