DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

One Colour, Three Spellings: HEX, RGB and HSL (and How to Convert Between Them)

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 00ff, 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) };
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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: #3b82f6hsl(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);
Enter fullscreen mode Exit fullscreen mode

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)