Every colour on your screen is the same thing written three different ways. Once that clicks, #3b82f6, rgb(59, 130, 246) and hsl(217, 91%, 60%) stop being three mysteries and become one number you can read in whatever notation the situation hands you. Here's the whole picture, plus the maths to move between formats without a library.
A colour is just three numbers
Each pixel is three tiny lights — red, green, blue — set to some intensity from 0 to 255. HEX, RGB and HSL don't describe different colours; they encode those same three intensities differently. Converting never changes the colour, it only re-spells it. That single idea removes most of the confusion.
What the hex bytes mean
#3b82f6 is three bytes in base-16. 3b is red, 82 is green, f6 is blue — each 00–ff, i.e. 0–255. Reading it is just splitting six characters into three pairs:
function hexToRgb(hex){
hex = hex.trim().replace(/^#/, "");
if (/^[0-9a-fA-F]{3}$/.test(hex)) // #39f shorthand
hex = hex.split("").map(c => c + c).join(""); // -> 3399ff
if (!/^[0-9a-fA-F]{6}$/.test(hex)) return null;
return { r: parseInt(hex.slice(0,2),16),
g: parseInt(hex.slice(2,4),16),
b: parseInt(hex.slice(4,6),16) };
}
Note the 3-digit shorthand: #39f means #3399ff — each digit doubled. Easy to forget, so expand it before parsing.
RGB is additive
Screens emit light, so RGB is additive: start from black and add colour. All three channels at full make white; equal amounts make grey. This is why red + green light gives you yellow, not the muddy brown you'd get mixing paint. "More of every channel = brighter" holds.
Why HSL is more human
RGB matches the hardware; it's a terrible match for how people think. Nobody says "a bit less blue, a touch more green." They say "same colour, lighter" or "more muted." HSL re-parameterises the exact same space into Hue (which colour, an angle 0–360° round the wheel), Saturation (how vivid, 0–100%) and Lightness (how bright, 0–100%). Want a lighter shade of the same colour? Raise L, leave H and S. That's why designers live in HSL.
RGB to HSL: the interesting conversion
Normalise the channels to 0–1, take max and min. Lightness is their midpoint. Saturation is the spread, scaled by how close lightness sits to the extremes. Hue is an angle: whichever channel is the maximum tells you which third of the wheel you're in, and the other two say how far you've leaned toward a neighbour.
function rgbToHsl(r, g, b){
r/=255; g/=255; b/=255;
const max = Math.max(r,g,b), min = Math.min(r,g,b), d = max-min;
let h = 0, s = 0, l = (max+min)/2;
if (d !== 0){
s = l > 0.5 ? d/(2-max-min) : d/(max+min);
switch (max){
case r: h = (g-b)/d + (g < b ? 6 : 0); break;
case g: h = (b-r)/d + 2; break;
case b: h = (r-g)/d + 4; break;
}
h *= 60; // sector index -> degrees
}
return { h, s: s*100, l: l*100 }; // keep FLOATS internally
}
The reverse builds two anchors from lightness and saturation, then a small helper walks the same six 60° sectors, called once each with the hue shifted by +1/3, 0 and −1/3 to pull out R, G and B.
The rounding trap that breaks round-trips
HSL is usually shown as whole numbers — hsl(217, 91%, 60%). But if you store those rounded integers and convert back, you drift by a digit: #3b82f6 comes back as #3c83f6. The fix is to keep hue/saturation/lightness at full floating-point precision internally and round only for display. Do that and the round-trip is lossless: #3b82f6 → hsl(217.22, 91.22%, 59.80%) → back to exactly #3b82f6.
Bonus: should text be black or white?
Perceived brightness isn't the raw average of R, G, B. Screens encode colour with an sRGB gamma curve, so you first linearise each channel, then weight green heaviest (our eyes favour it):
function relLum(r, g, b){
const f = c => { c/=255; return c <= 0.03928
? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); };
return 0.2126*f(r) + 0.7152*f(g) + 0.0722*f(b);
}
const contrast = (a, b) => (Math.max(a,b)+0.05)/(Math.min(a,b)+0.05);
The WCAG contrast ratio runs 1:1 to 21:1; 4.5:1 is the AA bar for body text. Compare your background against black (luminance 0) and white (luminance 1) and pick the winner — a genuine accessibility check in a dozen lines.
Alpha, briefly
Each format has a fourth-value cousin — #rrggbbaa, rgba(), hsla() — where alpha runs 0 (clear) to 1 (opaque). Alpha isn't part of the colour; it's how much of the background shows through when the pixel is composited. It doesn't touch the H/S/L maths, so treat it as a passthrough.
Try the live converter — type or pick in any format, watch the other two and a big swatch update instantly, with the black-or-white text readout built in:
https://dev48v.infy.uk/solve/day23-color-converter.html
Top comments (0)