RGB to HSL Explained: Formulas for RGB, Hex, HSL and HSV Conversion, and Where HSL Falls Short
RGB describes a colour as amounts of red, green and blue light, which is how screens work and how nobody thinks about colour. HSL describes the same colour as a hue angle, a saturation and a lightness, which makes "the same blue but darker" a one-number change. The conversion is exact arithmetic, but it has a few traps: HSV is not HSL, rounding loses information, and HSL's lightness does not match what your eye perceives.
What H, S and L mean
Hue is an angle on the colour wheel from 0 to 360 degrees: 0 is red, 120 green, 240 blue, and 360 wraps back to red. Saturation runs from 0% (grey) to 100% (the most vivid version of that hue). Lightness runs from 0% (black) through 50% (the pure hue) to 100% (white).
hex rgb hsl
#ff0000 rgb(255 0 0) hsl(0 100% 50%) red
#3366cc rgb(51 102 204) hsl(220 60% 50%) medium blue
#1a3366 rgb(26 51 102) hsl(220 60% 25%) same hue, darker
#808080 rgb(128 128 128) hsl(0 0% 50%) grey: hue is irrelevant
That third row is the point of HSL. Halving the lightness darkens the blue without shifting its hue. Doing the same in RGB means changing all three channels in proportion and checking the result by eye.
The RGB to HSL formula
Scale each channel to the range 0 to 1, then find the largest and smallest values. Lightness is their average. Saturation is their difference relative to how far lightness is from the extremes. Hue depends on which channel is largest.
r, g, b = R/255, G/255, B/255
max, min = max(r,g,b), min(r,g,b)
d = max - min
L = (max + min) / 2
S = 0 if d == 0
d / (1 - |2L - 1|) otherwise
H = 0 if d == 0 (grey: undefined, use 0)
60 * (((g - b) / d) mod 6) if max == r
60 * (((b - r) / d) + 2) if max == g
60 * (((r - g) / d) + 4) if max == b
Worked example for #3366cc: r = 0.2, g = 0.4, b = 0.8. Max is 0.8 (blue), min is 0.2, d = 0.6. L = 0.5. S = 0.6 / (1 − 0) = 60%. Blue is largest, so H = 60 × ((0.2 − 0.4) / 0.6 + 4) = 60 × 3.667 = 220°. The result is hsl(220 60% 50%).
function rgbToHsl(R, G, B) {
const r = R / 255, g = G / 255, b = B / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
const l = (max + min) / 2;
let h = 0, s = 0;
if (d !== 0) {
s = d / (1 - Math.abs(2 * l - 1));
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h = (h * 60 + 360) % 360;
}
return [h, s * 100, l * 100].map((v) => Math.round(v * 100) / 100);
}
rgbToHsl(51, 102, 204); // [220, 60, 50]
HSL to RGB, and hex both ways
Going back uses chroma, the colour's intensity: C = (1 − |2L − 1|) × S. You place C and an intermediate value in the channels chosen by the hue sector, then add a constant to lift everything to the right lightness.
function hslToRgb(h, s, l) {
s /= 100; l /= 100;
const k = (n) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));
return [f(0), f(8), f(4)].map((v) => Math.round(v * 255));
}
hslToRgb(220, 60, 50); // [51, 102, 204]
const toHex = (rgb) => '#' + rgb.map((v) => v.toString(16).padStart(2, '0')).join('');
const fromHex = (hex) => hex.replace('#', '').match(/../g).map((x) => parseInt(x, 16));
toHex(hslToRgb(220, 60, 50)); // '#3366cc'
rgbToHsl(...fromHex('#3366cc')); // [220, 60, 50]
Hex is RGB written in base 16, so "hex to HSL" and "HSL to hex" are just the RGB conversion with a parsing or formatting step. Remember to expand three-digit shorthand first: #36c means #3366cc, not #303606.
Python has a built-in module, with one surprise: colorsys uses the order HLS, not HSL, and works in 0–1 ranges.
import colorsys
h, l, s = colorsys.rgb_to_hls(51/255, 102/255, 204/255) # note: h, l, s
print(round(h*360), round(s*100), round(l*100)) # 220 60 50
HSL is not HSV (or HSB)
HSV, also called HSB, uses the same hue but a different model for the other two axes. In HSV, 100% value with 100% saturation is the pure hue, and white is 0% saturation with 100% value. In HSL, the pure hue sits at 50% lightness and 100% lightness is always white. Photoshop, Figma's picker and many colour wheels use HSB, while CSS uses HSL. Copying numbers between them without converting gives the wrong colour.
#3366cc hsl(220, 60%, 50%) hsv(220, 75%, 80%)
HSL -> HSV: V = L + S_l * min(L, 1 - L)
S_v = 0 if V == 0 else 2 * (1 - L / V)
HSV -> HSL: L = V * (1 - S_v / 2)
S_l = 0 if L in (0, 1) else (V - L) / min(L, 1 - L)
The hue is identical in both models, so only saturation and the third value need converting.
Writing HSL in CSS
CSS Color Level 4 allows space-separated values with an optional alpha after a slash. hsla() is now simply an alias of hsl(). The older comma syntax still works everywhere.
.button {
--hue: 220;
background: hsl(var(--hue) 60% 50%); /* modern syntax */
border-color: hsl(var(--hue) 60% 35%); /* darker, same hue */
}
.button:hover { background: hsl(var(--hue) 60% 45%); }
.overlay { background: hsl(220 60% 50% / 0.4); } /* 40% opacity */
.legacy { color: hsla(220, 60%, 50%, 0.4); } /* equivalent */
This is where HSL earns its place. One custom property for the hue gives you a whole palette of shades and states, and theming comes down to changing a single number.
Rounding: why converted values drift
HSL is a continuous model, and tools usually round it to whole degrees and percentages. With only 101 steps each for saturation and lightness, many distinct RGB colours map to the same rounded HSL value, and converting back gives a neighbouring colour. Take every third value of each channel, about 636,000 colours, send them through whole-number HSL and back with the functions above, and roughly 89% do not return to the RGB value they started from.
The difference is usually one or two units per channel, invisible on screen but enough to fail a snapshot test or break a brand colour check. Keep the original hex as the source of truth, and treat HSL as a derived value. If you store HSL, keep at least one decimal place.
Hue is meaningless for greys, where saturation is 0. Most converters return 0 for hue, some return NaN, and CSS Color 4 calls it a "powerless" component. Handle that case explicitly if you generate palettes from hue.
Where HSL falls short, and OKLCH
HSL's lightness is a formula, not a measure of how bright a colour looks. hsl(60 100% 50%) (yellow) and hsl(240 100% 50%) (blue) have the same lightness value, yet yellow looks far brighter. Palettes built by stepping HSL lightness therefore have uneven contrast, and a text colour that passes WCAG contrast in one hue may fail in another.
CSS Color Level 4 adds oklch(), based on the Oklab perceptual colour space by Björn Ottosson. Its L is designed to track perceived lightness, so equal steps look equal across hues. Current versions of all major browsers support it.
/* same L, visibly similar lightness across hues */
--blue: oklch(60% 0.15 260);
--green: oklch(60% 0.15 145);
--orange: oklch(60% 0.15 55);
HSL remains the easiest model for quick adjustments and is supported everywhere. For design systems where consistent contrast matters, generate the palette in OKLCH and export hex as a fallback.
Sources and further reading
CSS Color Module Level 4 (W3C) defines hsl(), the space-separated syntax, powerless hue and oklch(), and includes reference HSL conversion code. The Python documentation describes colorsys and its HLS ordering. Björn Ottosson's article "A perceptual color space for image processing" introduces Oklab. WCAG 2.2 defines the contrast-ratio requirement mentioned above.
Frequently asked questions
How do I convert RGB to HSL?
Divide each channel by 255, then take the max and min. Lightness is their average, saturation is (max − min) / (1 − |2L − 1|), and hue comes from which channel is largest, scaled to 0–360°. For example, `rgb(51 102 204)` becomes `hsl(220 60% 50%)`.
How do I convert hex to HSL?
Parse the hex into RGB first: each pair of hex digits is one channel from 0 to 255, and three-digit shorthand like `#36c` expands to `#3366cc`. Then apply the RGB to HSL formula.
What is the difference between HSL and HSV?
They share the hue but model the other two axes differently. In HSL, the pure hue is at 50% lightness and 100% is white; in HSV (HSB) the pure hue is at 100% value. CSS uses HSL, while many design tools use HSB, so the numbers are not interchangeable.
Why doesn't HSL to RGB give me my original colour back?
Rounding. When HSL is stored as whole degrees and percentages, many RGB colours share the same rounded value, so the reverse conversion lands on a neighbouring colour. Keep the original hex, or store HSL with decimals.
How do I write HSL in CSS?
Use `hsl(220 60% 50%)`, or `hsl(220 60% 50% / 0.5)` for transparency. The comma syntax `hsl(220, 60%, 50%)` and `hsla()` still work and mean the same thing.
Is HSL good for accessible colour palettes?
Only partly. HSL lightness does not match perceived brightness, so the same L can pass contrast for blue and fail for yellow. Check contrast ratios explicitly, or build the palette in OKLCH.