HEX, RGB, HSL and the newer colour spaces

How HEX, RGB, HSL, LCH and OKLCH relate to each other, why an HSL palette goes muddy in the mid tones, and what gamut clipping and alpha compositing do to the colour you get.

A colour in CSS is three or four numbers plus a rule for reading them: the colour space. Palettes that look uneven on screen are usually stepped in a space that does not match how vision works.

HEX and RGB are the same numbers

#3B82F6 is three bytes in base 16: 3B is 59, 82 is 130, F6 is 246, so rgb(59, 130, 246) is the same colour in base 10, and a colour converter moves between them without loss.

Three digit shorthand duplicates each digit rather than padding it, so #F80 expands to #FF8800, not #F08000; only 4,096 colours have a short form. #RGBA adds an alpha digit the same way and #RRGGBBAA spells out all four bytes, that alpha byte running 0 to 255 (1A is 10%, 80 is 50%, CC is 80%) while every other alpha in CSS runs 0 to 1. Modern syntax also takes space separated components with alpha after a slash: rgb(59 130 246 / 50%).

These numbers are not amounts of light. sRGB encodes each channel through a transfer curve of roughly 2.2, so 128 emits about 21.6% of the light that 255 does, not half, and every blend computed on the raw values inherits that curve.

HSL, HSV and the lightness that is not lightness

HSL rearranges the same RGB cube into a cylinder: hue is an angle (0 red, 120 green, 240 blue), saturation is distance from the central grey axis, and lightness runs from 0 through 50%, the pure hue, to 100%, white. HSV swaps lightness for value, whose top is the pure hue rather than white, so tints come from dropping saturation. Neither knows anything about perception.

ColourHSLRelative luminanceOKLCH lightness
#FFFF00hsl(60 100% 50%)0.9280.97
#0000FFhsl(240 100% 50%)0.0720.45

Both claim 50% lightness; one emits about thirteen times the light of the other. A token row with every hue pinned to 50% reads as a jumble of weights.

Stepping HSL lightness at fixed hue and saturation fails for the same reason, plus one more: saturation is measured against the room available at that lightness, so the 50% step is the most colourful and everything either side washes out. The hue angle also holds while perceived chroma collapses, which is what makes mid steps look muddy. They are not the base colour darkened, they are the base colour greyed by an amount nobody chose.

Yellow shows the worst of it. Lowering HSL lightness walks a yellow straight into olive, because a dark yellow at that hue angle simply is olive, and keeping it yellow means rotating the hue towards orange on the way down. Blue has the opposite trouble, sitting so low in luminance that its usable variation all lives above 50%.

LCH and OKLCH

LCH is the polar form of CIE Lab, built from colour matching experiments: lightness 0 to 100, chroma (colourfulness, no fixed maximum) and a hue angle. OKLCH is the same shape over Oklab, which corrects the hue shifts Lab is known for, most visibly blues drifting purple as they lighten.

.a { color: oklch(0.62 0.21 260); }        /* L 0 to 1, C about 0 to 0.37, H in degrees */
.b { color: oklch(62% 0.21 260 / 50%); }   /* percentages and alpha both allowed */
.c { color: lch(52% 82 285); }             /* L runs 0 to 100 here */

Perceptual uniformity means equal numeric distance is close to equal perceived distance. A ramp in steps of 0.08 lightness looks like even steps, a row of hues pinned to one lightness reads as one row, and changing lightness leaves the hue where you put it. Interpolation can use the space too, as in linear-gradient(in oklch, #0000FF, #FFFF00). One caution: OKLCH lightness is much closer to apparent brightness than HSL, but it is not the WCAG luminance formula, so text pairs still go through a contrast checker.

Gamut and clipping

A gamut is the set of colours a space can represent. sRGB is the long standing default; Display P3 covers roughly a quarter more of visible colour, almost all of the gain in deep reds and greens. Chroma in OKLCH is unbounded, so writing a colour no screen can show is easy.

@supports (color: color(display-p3 1 1 1)) {
  .vivid { background: color(display-p3 0.9 0.1 0.14); }
}

Per channel clipping brings an out of gamut colour back by pinning each channel at its limit independently, which shifts the hue and flattens neighbouring shades onto one value, so gradients band and detail vanishes. CSS Color 4 maps instead: hold lightness and hue, reduce chroma in OKLCH until the colour fits.

This is why a screenshot of a vivid photo can look flat. The photo carries a P3 profile and the display shows it in full; an export converted to sRGB squeezes those saturated pixels into a smaller volume, and reds and greens have furthest to travel.

Alpha compositing

A semi transparent colour over a backdrop produces a third, opaque colour, one channel at a time:

result = alpha * source + (1 - alpha) * backdrop

Black at 50% over white gives (127.5, 127.5, 127.5), or #808080. That coincidence is where "opacity 50% of black is grey" comes from, and it holds on white and nowhere else: the same black over #3B82F6 gives about #1E417B, a dark blue, and over a photograph it gives a different colour in every pixel. Semi transparent black is a darkening operator, not a grey.

Compositing runs on the encoded values, so that #808080 has a relative luminance of about 0.216 rather than 0.5. Measure the composited hex, never the declared colour. Alpha does not stack as expected either: two 50% layers cover 75%, because the second only acts on what the first let through.

Tints, shades, tones and harmonies

TermTraditional recipeIn OKLCH
Tintbase plus whiteraise L, lower C slightly
Shadebase plus blacklower L
Tonebase plus greylower C at fixed L

Harmonies are hue offsets. They pick an accent; they do not build a system.

HarmonyOffsetsGood for
Complementary+180°one accent against a dominant hue, though it vibrates when both are saturated
Analogous±30°calm groupings, backgrounds, states within a family
Triadic+120°, +240°categorical sets such as chart series

Offsets on the RGB wheel are not perceptually even, since that wheel spends a disproportionate arc on green, so treat a computed harmony as a first draft and adjust by eye. A palette generator building tints, shades and harmonies from one base gets that draft on screen quickly.

The neutral ramp needs its own decision. A pure grey, red green and blue equal, sits beside a coloured interface looking dead and faintly dirty. Give the neutrals a small chroma, roughly 0.005 to 0.02 in OKLCH, near the brand hue: warm greys around 60° to 90° read like paper, cool greys around 250° read technical. Taper that chroma towards the lightest steps so backgrounds do not look tinted.

Colour blindness

Around 1 in 12 men and 1 in 200 women have a colour vision deficiency. Red-green types account for almost all of it, deuteranomaly by a wide margin; blue-yellow tritan types affect fewer than 1 in 10,000.

The confusions that matter in interfaces are red against green at similar lightness, green against brown and orange, purple against blue, and pink against grey. Protan vision also darkens reds, so a red chosen for urgency can end up the quietest thing on screen.

Colour must therefore never be the only carrier of meaning. Pair every state with a label, an icon, a shape or a position, and make paired states differ in lightness as well as hue, because a red and a green at matching OKLCH lightness are the worst possible combination. Converting a screenshot to greyscale catches those collisions in seconds.