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.
| Hue | Colour | Relative luminance | Contrast on black |
|---|---|---|---|
| 0° | #FF0000 | 0.2126 | 5.25:1 |
| 60° | #FFFF00 | 0.9278 | 19.56:1 |
| 120° | #00FF00 | 0.7152 | 15.30:1 |
| 180° | #00FFFF | 0.7874 | 16.75:1 |
| 240° | #0000FF | 0.0722 | 2.44:1 |
| 300° | #FF00FF | 0.2848 | 6.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
| Notation | What the numbers mean | What it is good at | Where 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.