Colour

RGB, HSL and OKLCH: three ways to name a colour

Hex and rgb() are one model in two spellings. The useful question is which notation to reach for when you are changing a colour rather than copying one.

Ask a designer which colour notation they type and the answer is usually "whatever the design file handed me". That is a good answer for a brand blue and a poor one for everything else. #3B82F6 is a fine thing to paste and a bad thing to reason with: there is nothing in those six characters you can turn up or down. The question worth settling is which notation to reach for when you are changing a colour rather than copying one.

RGB is the screen's own model

A pixel is three emitters, and the notation matches the hardware: red, green and blue, each a number from 0 to 255 in the usual eight-bit form. The mixing is additive, so the numbers add light rather than subtract pigment. All three at zero is black, all three at maximum is white, and there is no yellow emitter: rgb(255 255 0) reads as yellow because red light and green light together excite the eye the way yellow light does.

The limitation is not the eight-bit range. It is that the channels are not perceptually independent, so the same numeric step in two channels is not the same visual step. The weights that turn a colour into a brightness value show the split: green carries about 0.7152 of the total, red 0.2126, blue 0.0722. Move the green channel by ten and the colour gets noticeably lighter. Move blue by ten and it barely stirs.

So RGB is a storage format that happens to be exact. That is worth a great deal when a value must not shift, and it is a poor model for deciding what the value should be.

Hex is the same colour in base sixteen

#3B82F6 and rgb(59 130 246) are the identical colour. Hex is not a separate model — it is the same channels written in base sixteen, two digits each, with a hash in front. Every pair runs from 00 to FF, which is 0 to 255 in decimal.

One multiplication per digit settles it. 3B is three sixteens plus eleven, which is 59. 82 is eight sixteens plus two, which is 130. F6 is fifteen sixteens plus six, which is 246. That is the whole of the conversion.

#3B82F6                 hex, three pairs in base sixteen
rgb(59 130 246)         the same channels in decimal
rgb(23.1% 51.0% 96.5%)  the same channels as proportions of 255

HSL is what made colour editable

HSL keeps the same displayable colours and re-labels them. Hue is an angle in degrees around a wheel, with red at zero. Saturation is a percentage measuring how far the colour has travelled from a grey of the same lightness. Lightness runs from black at zero to white at a hundred.

It took over the design tools of its era because it matches the sentences people say out loud. "Make it a bit more blue" is a hue change, and "take the saturation down" is a saturation change. Before HSL, editing a colour meant nudging channels and re-checking by eye, because one channel moves hue, saturation and brightness at once.

The sample blue converts like this. The widest channel is 246 and the narrowest is 59, so lightness is their midpoint over 255: 0.598, near enough to 60 per cent. The gap between them is 0.733 as a proportion, and dividing that by the room available at this lightness gives the saturation, about 91 per cent. The hue sits in the blue sector at 240 degrees, offset by red minus green over the gap, which is minus 0.379, or 22.8 degrees, so 217.

#3B82F6            the colour
rgb(59 130 246)    channels, decimal
hsl(217 91% 60%)   hue, saturation, lightness

HSL's lightness is a lie

Now the catch, and it is the most useful thing on this page. HSL's lightness is the arithmetic midpoint of the widest and narrowest channels. It says nothing about how bright the colour looks. It is a fact about the numbers dressed as a fact about the light.

Test it on two colours HSL swears are equally light. hsl(60 100% 50%) is #FFFF00, and hsl(240 100% 50%) is #0000FF. Both are lightness fifty, both fully saturated. Put each through the sRGB weights. The yellow is 0.2126 plus 0.7152, which is 0.9278. The blue is 0.0722. The yellow carries almost thirteen times the light.

Against black, adding the 0.05 floor to each side and dividing: the yellow gives 0.9778 ÷ 0.05, which is 19.56 to 1. The blue gives 0.1222 ÷ 0.05, which is 2.44 to 1. Two colours the notation calls equally light, one comfortable as text on black and one close to invisible. Against white the pair inverts: the yellow manages 1.07, and the blue reaches 8.59.

HueColourRelative luminance Contrast on black
#FF00000.21265.25:1
60°#FFFF000.927819.56:1
120°#00FF000.715215.30:1
180°#00FFFF0.787416.75:1
240°#0000FF0.07222.44:1
300°#FF00FF0.28486.70:1

Every colour here is written as hsl(H 100% 50%), so the stated lightness is identical in all six rows. The luminance column applies the sRGB channel weights, and the ratio column puts the result against black. One lightness value, an eightfold spread in how much light the reader gets.

Hold saturation and lightness, step the hue, and the palette you get is a row of swatches of wildly different apparent brightness. The blues come out heavy, the yellows glare, and the row does not read as one family however consistently it is spelled.

Gradients suffer the same way. Interpolate #0000FF to #FFFF00 one channel at a time, the way a naive blend does, and the middle is #808080, the flat grey behind every muddy gradient, because the midpoint of each channel pair is 127. Route the pair through HSL instead and the hue stays saturated the whole way, passing through cyan and green, so you trade the mud for an acid green stretch that neither end of the gradient prepares you for. Neither route holds the apparent brightness steady, because neither model has a channel for it.

OKLCH measures lightness perceptually

OKLCH re-labels the same idea on top of a perceptual space called Oklab, fitted to measurements of how people discriminate between colours, so that equal numeric steps produce roughly equal visual steps. That property is the entire point, and it is what HSL lacks.

L runs from 0 to 1, and 0.5 sits near the visual middle rather than at the midpoint of the channels, so holding it constant across a set of hues leaves the set looking even. C is chroma, roughly "how colourful", zero for a neutral grey and absolute rather than a percentage of anything. H is an angle in degrees, but the wheel is positioned by the perceptual model: the sRGB primaries land near 29 degrees for red, 142 for green and 264 for blue.

Chroma is not saturation

In HSL, the most colourful colour at lightness 50 per cent is a different absolute amount of colour for every hue, because a yellow at that lightness holds far more chroma than a blue can. Ask for full saturation on both and one shouts while the other mumbles. Chroma is absolute, so you set the amount you want and step the lightness.

The catch is that chroma has no upper bound the way saturation does. C: 0.3 is comfortably inside sRGB on some hues and well outside it on others, and sRGB is a triangle sitting inside a larger space. A value the display cannot show is brought back to the nearest colour it can, and nothing tells you this happened. Push a chroma past the edge of the triangle and the colour stops getting more colourful and starts losing saturation instead: C: 0.2 and C: 0.3 on a hue whose limit is 0.18 render identically. Move the lightness rather than push the chroma.

Where each notation belongs

NotationWhat the numbers mean What it is good atWhere it breaks
hex and rgb() Red, green and blue as additive light, 0 to 255, or the same channels as base-sixteen pairs Holding an exact value from a design file or a brand guide, and handing a colour to another program unchanged Editing. The channels are not perceptually independent, so a step in one is not a step in another, and there is no control for hue or for brightness
hsl() A hue angle, a saturation as a share of the most colourful colour at that lightness, and a lightness that is the midpoint of the widest and narrowest channels Fast hand edits, and saying a colour out loud. The notation most CSS and most older design tools still speak Anything generated. Equal lightness values are not equally bright, so palettes, ramps and gradients drift as the hue moves
oklch() A perceptual lightness from 0 to 1, an absolute chroma, and a hue angle in the perceptual space Building palettes, tints, shades, gradients and accessible systems, where the set has to hold together across hues Values outside the displayable range are pulled back silently, and a tool that does not parse the notation has to be handed something else

Read the last column first. The reason to choose a notation is almost always the failure mode you can live with rather than the feature list: RGB cannot be adjusted, HSL cannot be trusted across hues, and OKLCH cannot tell you when it has left the range your screen covers.

My rule is blunt. Keep the value in hex when exactness is the job: brand colours, values lifted from a design file, anything that has to match a printed sample. Reach for HSL when you are adjusting one colour by hand and the drift does not matter. Reach for OKLCH whenever the colour is generated rather than chosen: a ramp, a hover state, a tint, a gradient, a chart series, a whole theme derived from one input.

Browser support is no longer the reason to hesitate, since the major engines have read the notation for a few years and CSS interpolates gradients in it directly. The remaining cost is at the edges: a build step or a handoff format that does not parse it has to be handed a converted value first, and that conversion is where the exactness goes.

Conversion is lossy in the last digit

Every conversion between these notations runs through floating point and rounds at the end, so the last digit is not stable. The sample blue is the demonstration. Its exact values are a hue of 217.2 degrees, a saturation of 91.2 per cent and a lightness of 59.8 per cent. Written the way anyone would write them, hsl(217 91% 60%), and converted back, the arithmetic rebuilds the chroma from the rounded saturation and lightness and lands on #3C83F6 rather than #3B82F6: a step out on red and a step out on green.

A step is nothing in isolation and everything in aggregate. Round-trip a value a few times through a design tool that stores in HSL and it can drift several steps, which is how a brand colour ends up slightly wrong across a set of files with nobody able to say where it moved. So convert to think, paste to ship: reason in whichever notation helps, then paste the value the tool gives you rather than retyping it from the notation you were thinking in.

Where the tools fit. The Colour Studio shows a colour in several notations at once as you edit it, which is the quickest way to see that #3B82F6, rgb(59 130 246) and hsl(217 91% 60%) are one colour, and to watch what a hue step does to apparent brightness when the lightness value does not move.

The Palette Generator is where the generating happens: build the ramp, then read the lightness values across it. A set built by stepping hue at a fixed lightness will wander. A set built on a perceptual lightness will not.

What I reach for first

Copying a colour: hex, unconverted. Changing one by hand: HSL, because it is quick and the drift does not matter for a single edit. Generating anything: OKLCH, the one notation where a constant lightness value means a constant lightness.

One check is worth running on your own work. Take a palette you already have and convert the entries to OKLCH. If two colours doing the same job — two buttons of equal importance, two steps of one ramp — sit far apart in the lightness column, they are not doing the same job to the eye whatever the file says. And the notation is not a matter of taste. Hex and rgb() are one model in two spellings, so choosing between them changes nothing. HSL and OKLCH differ in what the middle number means, and that is the choice that decides whether a generated palette holds together.