Design

Color spaces: HEX, RGB, HSL and why HSL lies about brightness

#3366CC, rgb(51, 102, 204) and hsl(220, 60%, 50%) are the same color, written in three languages. No mystery so far. The trouble starts the moment you trust HSL’s "L": that "50% lightness" is not the brightness your eye perceives. Yellow and blue can share the exact same 50% L in HSL and yet yellow emits nearly thirteen times more light. This guide shows what HEX, RGB and HSL actually represent, runs the arithmetic that proves HSL lies about brightness, computes real luminance, and explains why CSS Color 4 brought in OKLCH to fix it. Try every conversion in the [color converter](tool:conversor-cores) as you read.

J-Kit15 min readIntermediate
  • Color
  • CSS
  • HSL
  • OKLCH
  • sRGB

Key takeaways

  • HEX and RGB are the same sRGB color by channel; HSL and HSV just reproject those channels, none of them measures perceived brightness.
  • HSL’s "L" is geometry (the average of the highest and lowest channel), not luminance: yellow and blue at L = 50% differ almost 13× in real light.
  • Relative luminance weights the linearized channels by 0.2126 / 0.7152 / 0.0722, that is what contrast and the eye see, not HSL’s L.
  • OKLCH and OKLab (CSS Color 4) are perceptually uniform: smooth gradients and steps of L that translate into steps of contrast. Baseline since May 2023.

What a color space is

A screen produces color by mixing three lights: red, green and blue. That is additive synthesis, all off gives black; all at maximum gives white. A color space is simply a coordinate system for pointing at a specific color within what the device can display. Almost the entire web uses the sRGB gamut, the standard color set of ordinary monitors; HEX, RGB, HSL and HSV are different notations for points inside that same gamut.

Two distinctions organize everything that follows. First: switching notation (RGB to HSL) does not change the color, only how you describe it; switching gamut (sRGB to Display P3) can change which colors exist. Second, less obvious and the heart of this guide: describing a color is not the same as measuring how it is perceived. HEX, RGB, HSL and HSV describe well; none of them measures perceived brightness. For that there is a parallel lineage of spaces, CIELAB, and more recently Oklab, built from how the human eye actually sees.

  1. 1931CIE XYZ and the standard observer

    The CIE maps every visible color from color-matching experiments with human observers, creating the XYZ space, the mathematical basis for all the others.

  2. 1976CIELAB (CIE L*a*b*)

    The CIE publishes L*a*b*, the first space designed to be perceptually uniform: equal distances should look like equal differences.

  3. 1996sRGB becomes the web standard

    HP and Microsoft propose sRGB as the internet’s default space; it becomes the IEC 61966-2-1 standard in 1999. It is the gamut HEX, RGB and HSL assume by default.

  4. 2020Oklab

    Björn Ottosson publishes Oklab, fixing CIELAB’s hue and lightness prediction, especially in the blues, for a screen’s gamut.

  5. 2022–2023CSS Color 4 in browsers

    oklch() and oklab() land in Safari 15.4 (2022) and in Chrome, Edge and Firefox in 2023; they become Baseline "widely available" in May 2023.

HEX and RGB: the same color, by channel

RGB gives each channel’s intensity from 0 to 255. rgb(51, 102, 204) means: low red, medium green, high blue, a royal blue. HEX is the same thing in base 16 (hexadecimal): each pair of digits is one channel from 00 to FF, i.e. 0 to 255. So #3366CC = rgb(51, 102, 204): 33 in base 16 is 51, 66 is 102, CC is 204. There is no new information in HEX, just a shorter spelling, perfect for pasting into CSS.

HEX  ->  RGB   (cada par e um canal, base 16 -> base 10)
  33 = 3*16 + 3   = 51
  66 = 6*16 + 6   = 102
  CC = 12*16 + 12 = 204
#3366CC  =  rgb(51, 102, 204)

HEX de 3 digitos: #39C  ->  #3399CC  (cada digito e duplicado)
HEX de 8 digitos: #3366CC80  ->  os dois ultimos (80) sao o alfa
HEX is RGB in hexadecimal; the 3- and 8-digit shorthands.

Two HEX conveniences: the 3-digit short form (#39C) doubles each digit to make 6, and the 8-digit form appends an alpha pair at the end. Alpha 80 in base 16 is 128, which divided by 255 gives about 0.50, half transparency. The modern CSS Color 4 syntax also accepts space-separated channels with alpha after a slash: rgb(51 102 204 / 50%) is the current way to write rgba(51, 102, 204, 0.5). Reach for RGB/HEX when you are speaking the machine’s language: channels, screens, code.

HSL: intuitive, but it lies about brightness

Nobody thinks "I need 40 more green and 20 less blue". We think "I want that same blue, just lighter". HSL (Hue, Saturation, Lightness) reorganizes color around that intuition: hue (H) is an angle from 0 to 360° on a wheel; saturation (S) runs from gray to pure color; and lightness (L) supposedly runs from black to white. The fatal detail is in L. It is not measured from the eye: it is a trivial reprojection of RGB, the arithmetic mean of the highest and lowest channel. Pure geometry of the RGB cube.

L = (max + min) / 2
max
the largest of the three normalized channels (0 to 1)
min
the smallest of the three normalized channels (0 to 1)
HSL lightness: just the average of the highest and lowest channel. It does not weight the eye’s sensitivity to each color.
Y = 0.2126 * R_lin + 0.7152 * G_lin + 0.0722 * B_lin
Y
relative luminance, from 0 (black) to 1 (white)
R_lin, G_lin, B_lin
linearized channels (with the sRGB gamma curve undone)
0.2126 / 0.7152 / 0.0722
weights of human sensitivity to red, green and blue
Relative luminance (WCAG / sRGB): weights each channel by the eye’s real sensitivity, after undoing gamma. Note: green counts ~10× more than blue.

The two formulas have nothing in common. HSL’s L knows none of those weights: to it, "pure" red, green and blue are all identical, an L of 50%. Relative luminance knows the eye is ten times more sensitive to green than to blue. The result is the proof that HSL lies: a handful of colors with the same 50% L emit radically different amounts of light. Below, the real luminance of six colors that HSL insists on calling "50% light".

Yellow #FFFF0092.78%
Cyan #00FFFF78.74%
Green #00FF0071.52%
Magenta #FF00FF28.48%
Red #FF000021.26%
Blue #0000FF7.22%
All six of these pure colors have L = 50% in HSL. Their real relative luminance (white = 100%) runs from 7% for blue to 93% for yellow, a factor of nearly 13×. This is the visual proof that HSL’s L is not brightness.
View the data
CategoryValue
Yellow #FFFF0092.78%
Cyan #00FFFF78.74%
Green #00FF0071.52%
Magenta #FF00FF28.48%
Red #FF000021.26%
Blue #0000FF7.22%
92.78%luminance of yellow, at HSL L = 50%
7.22%luminance of blue, at the same HSL L = 50%
≈ 12.9×how much lighter yellow is than blue, at the same L

Step-by-step conversion (and the luminance HSL hides)

Let us convert #3366CC all the way to HSL and then compute the real luminance it carries, the arithmetic HSL never shows. First normalize each channel by dividing by 255; from there come lightness, saturation and hue by geometry; then linearize the channels and apply the perceptual weights:

#3366CC -> rgb(51, 102, 204)

1) Normaliza  (canal / 255)
   R = 51/255  = 0.20     G = 102/255 = 0.40     B = 204/255 = 0.80

2) HSL  (geometria do cubo RGB)
   max = 0.80   min = 0.20   delta = 0.60
   L = (max + min) / 2            = 0.50   -> 50%
   S = delta / (1 - |2L - 1|)     = 0.60   -> 60%
   H = ((R - G)/delta + 4) * 60   = 220 graus
   => hsl(220, 60%, 50%)

3) Luminancia relativa  (lineariza, depois pesa)
   lin(c) = ((c + 0.055)/1.055)^2.4    para c > 0.04045
   R_lin = 0.0331   G_lin = 0.1328   B_lin = 0.6038
   Y = 0.2126*0.0331 + 0.7152*0.1328 + 0.0722*0.6038
   Y = 0.146   -> 14.6% da luz do branco

O HSL diz L = 50%.  A luminancia real e 14.6%.  Nao sao a mesma coisa.
From HEX to HSL by geometry, then to real luminance by perception. Same pixel: L = 50%, luminance = 14.6%.

Look at the jump in step 3. HSL declares "L = 50%" for #3366CC, yet the pixel emits only 14.6% of a white’s light. The blame is double: blue, the dominant channel, has the smallest perceptual weight (0.0722), and linearization pushes mid values down further (a channel at 0.80 becomes 0.60 of light; one at 0.40 becomes just 0.13). HSL ignores both. Now the pair that closes the argument, two HEX with the same L and luminances on opposite ends of the scale:

  1. Yellow #FFFF00rgb(255, 255, 0), normalized (1, 1, 0). max = 1, min = 0, so L = (1 + 0) / 2 = 0.50, HSL says 50%. Linearizing, the channel at 1 stays 1 and the channel at 0 stays 0, so Y = 0.2126×1 + 0.7152×1 + 0.0722×0 = 0.9278, that is, 92.78% of a white’s light.
  2. Blue #0000FFrgb(0, 0, 255), normalized (0, 0, 1). max = 1, min = 0, so L = 0.50, HSL again says 50%. But Y = 0.2126×0 + 0.7152×0 + 0.0722×1 = 0.0722, only 7.22% of a white’s light.
  3. The verdictTwo HSL colors identical in L (50%), and yellow emits nearly 13 times more light than blue. This is why the same "L" yields completely different perceived brightness, and why blue text can fail a contrast check that the same tone in yellow would pass easily. HSL’s "50%" is not a promise about brightness; it is just a point in the middle of the cube.
The same color in each notation, and the real luminance only the last column reveals.
HEXRGBHSLHSVLuminance Y
#3366CCrgb(51, 102, 204)hsl(220, 60%, 50%)hsv(220, 75%, 80%)14.6%
#FFFF00rgb(255, 255, 0)hsl(60, 100%, 50%)hsv(60, 100%, 100%)92.8%
#0000FFrgb(0, 0, 255)hsl(240, 100%, 50%)hsv(240, 100%, 100%)7.2%
Reproduce the math: type #3366CC, #FFFF00 or #0000FF and compare HSL, HSV and the XYZ Y value (relative luminance × 100).Open the tool full page

OKLCH: when "L" means brightness again

If the problem is that HSL’s L does not match brightness, the fix is to use a space whose lightness axis is perceptual. That is what Björn Ottosson published in 2020 with Oklab, and its polar-coordinate version, OKLCH (Lightness, Chroma, Hue). Ottosson optimized the space numerically over a large set of visually similar colors, correcting exactly where CIELAB erred most: hue prediction in the blues. In OKLCH an L of 0.5 really does look halfway between black and white, for any hue. CSS Color 4 adopted all of it: oklch(), oklab(), lab(), lch() and the color() function for wide gamuts.

HSL

  • L is pure geometry (average of the RGB max and min), not perceived brightness.
  • Interpolating a gradient in HSL crosses gray or "muddy" zones.
  • Rotating hue changes brightness with it: the same L looks lighter in yellow than in blue.
  • Handy as a quick shortcut to lighten/darken within a single hue.

OKLCH

  • L is perceptual lightness: 0.5 looks halfway to the eye, in any hue.
  • Interpolating in Oklab gives smooth transitions, no dead gray, it is the CSS Color 4 default.
  • L, C and h are nearly orthogonal: you change one without wrecking the other two.
  • A basis for accessible palettes: equal steps of L become equal steps of contrast.

The difference shows up dramatically in gradients. Ottosson’s own motivation was that "blending two colors should result in even transitions". That is why CSS Color 4 picked Oklab as the default interpolation space for gradients and the color-mix() function: the route between two colors becomes perceptually straight, without the gray that appears when interpolating in sRGB or the uneven banding of HSL. If you build color ramps for a color generator or for design tokens, it is the difference between a scale that "breathes" evenly and one that stalls in the middle.

Why gradients in HSL (and sRGB) go gray

A gradient from pure blue (0, 0, 255) to pure yellow (255, 255, 0) interpolated straight in sRGB passes through the midpoint (127, 127, 127), gray. You asked for blue to yellow and got a muddy band in the middle, because the numeric average of two opposite colors is a desaturated gray.

In HSL, hue interpolation avoids the gray but crosses stretches where perceived brightness dips and rises, creating "muddy" bands. Oklab was designed so the path between two colors is perceptually straight, which is why CSS Color 4 uses it as the default interpolation space for gradients and color-mix().

The sRGB gamma curve (why you must linearize)

The 0–255 values of an sRGB channel are not proportional to emitted light. The encoding passes through a transfer function: a small linear segment (c ≤ 0.04045, where light is c / 12.92) and then a power, ((c + 0.055) / 1.055) raised to 2.4, which together approximate a gamma of about 2.2. Only after undoing this curve do the weights 0.2126 / 0.7152 / 0.0722 make physical sense.

Practical consequence: a mid gray of channel 128 (halfway in number) emits only about 21% of a white’s light, not 50%. Gamma is also why resizing images without linearizing leaves edges too dark, the same mistake HSL’s L makes by ignoring the curve.

What changes with Display P3 and Rec. 2020

sRGB is the smallest of the modern gamuts. Display P3, used in Apple screens and many OLEDs, covers about 25% more colors, reds and greens more saturated than sRGB simply cannot reach. Rec. 2020, the HDR and 8K target, is wider still.

In CSS you reach these gamuts with the color() function: color(display-p3 1 0 0) is a red outside sRGB. HEX, RGB and HSL are, by definition, stuck in sRGB, switching notation never reaches those colors; only switching gamut does.

Which to use when

HEX
The CSS and design-token default: short, comma-free, easy to copy. Use it to pin static colors.
RGB(A)
When you think in channels or need explicit alpha. The modern form is rgb(51 102 204 / 50%), with spaces and a slash.
HSL(A)
Good for quick hue tweaks and for varying tones within a color, but remember L is not perceived brightness.
OKLCH
The current pick for palettes, gradients and accessible scales: perceptual L, predictable hue, smooth interpolation.
CMYK / LAB
CMYK is for print (ink, subtractive synthesis, smaller gamut); LAB is for measuring color difference between two tones.

A note on measuring how "different" two colors are: in perceptual spaces like LAB, the distance between two points is called ΔE. A difference of about 1 ΔE sits at the threshold where a difference starts to be perceptible side by side; some studies place the just-noticeable difference (JND) near 2.3. That is why two "almost identical" blues in HEX can be obvious to the eye, and two "very different" yellows by the numbers can look like the same tone, distance in HEX is not the distance you see.

Alpha is orthogonal to all of this: it is the color’s opacity, from 0 (transparent) to 1 (opaque), available as rgb(... / a), hsl(... / a) or the final pair of an 8-digit HEX. And screen color is always sRGB and additive, while print color is CMYK and subtractive, with a smaller gamut, which is why a vivid orange on the monitor can come out duller on paper. It is not a conversion error; it is the print gamut not reaching that color.

Frequently asked questions

Are HEX and RGB the same thing?
Yes, they describe the same sRGB color. HEX is just RGB written in base 16: each pair of digits (00 to FF) is a channel (0 to 255). #3366CC and rgb(51, 102, 204) are identical, and the modern form rgb(51 102 204) too.
Why is HSL’s "L" not the brightness of the color?
Because L is just the average of the RGB max and min channel, geometry, not perception. It treats pure red, green and blue as equally light (all L = 50%), but the eye is about 10× more sensitive to green than to blue. That is why yellow and blue at L = 50% differ nearly 13× in real light.
What is relative luminance and how is it computed?
It is the perceived brightness of a color, from 0 (black) to 1 (white). First you linearize each channel (undo the sRGB gamma), then apply Y = 0.2126×R + 0.7152×G + 0.0722×B. The weights come from the eye’s sensitivity. It is the same measure contrast uses, and the XYZ Y value in the converter is it multiplied by 100.
What is OKLCH and why use it over HSL?
OKLCH is the polar form (lightness, chroma, hue) of Oklab, a perceptually uniform space created by Björn Ottosson in 2020 and adopted by CSS Color 4. Unlike HSL, OKLCH’s L matches perceived brightness in any hue, and interpolating colors in it yields smooth gradients. It already works in every modern browser (Baseline since May 2023).
Why do gradients sometimes get a gray band in the middle?
Because interpolating two opposite colors straight in sRGB passes through the numeric average, which is a desaturated gray, blue to yellow crosses (127, 127, 127). Interpolating in Oklab avoids that, keeping the route perceptually straight. That is why CSS Color 4 uses Oklab as the default interpolation space for gradients and color-mix().
Why does my color look different when printed?
The screen uses sRGB (additive, light); printing uses CMYK (subtractive, ink), with a smaller gamut. Some vivid screen colors do not exist in CMYK, so they come out duller, a gamut limitation, not a conversion error. Display P3 and Rec. 2020 screens go the other way: they have a larger gamut than sRGB.

HEX, RGB, HSL and HSV are languages for the same sRGB color, and all describe it well, but none measures brightness. HSL’s "L" is the average of two channels, not luminance: yellow and blue at the same 50% L differ nearly 13× in the light they emit. What actually sees brightness is relative luminance (linearized channels, weighted by 0.2126 / 0.7152 / 0.0722), the same basis as contrast. To work with brightness, gradients and palettes the way the eye sees them, the current path is CSS Color 4’s OKLCH.

Sources & references

  1. W3C, CSS Color Module Level 4
  2. Björn Ottosson, A perceptual color space for image processing (Oklab, 2020)
  3. W3C, A Standard Default Color Space for the Internet: sRGB (1996)
  4. W3C, WCAG 2.2: relative luminance (definition)
  5. MDN, oklch() (support and Baseline)
  6. MDN, color-mix() (default Oklab interpolation)
  7. MDN, <color> CSS data type