#f0c
Bytes, doubled digits, alpha pairs, a purple named after somebody, and one format that finally lets you do arithmetic.
Every front-end person can read a hex code at a glance and I’d guess most of us can’t explain one. I couldn’t, for years. I could look at #3a7bd5 and tell you “blue, sort of dusty” without being able to say which part was doing that, which is a fine way to work right up until you need to make it 10% lighter.
So here’s the anatomy, since it turns out to be about five minutes of arithmetic and then you have it for good.
Two digits is one byte, and that’s the whole reason
A hex code is three numbers wearing a disguise. #RRGGBB is red, green, blue, each one a pair of digits in base 16, each pair running 00 to ff.
Base 16 means a digit holds 16 values: 0 through 9, then a through f, so a is 10 and f is 15. Two digits gets you 16 × 16 = 256 values, 00 through ff, which is 0 through 255. And 0 through 255 is exactly one byte.
That’s it, that’s the reason it’s hex and not something friendlier. A color channel is stored in a byte, and a byte is always exactly two hex digits. No padding, no ambiguity, no variable width. Decimal can’t do that: 255 is three characters and 7 is one, so you’d need separators. Hex lines up with the storage so precisely that the notation is just a transcription of the bytes. That’s how it came out of X11 and into HTML’s bgcolor and never left. Six digits, three bytes, 16,777,216 combinations. The 16.7 million printed on the side of every monitor box in the 90s.
The shorthand doubles, it doesn’t pad
#f0c is #ff00cc. Each digit gets repeated. It is not padded with a zero and it is not truncated.
I think this one catches people because “shorthand” sounds like it should be lossy in the obvious direction, and the doubling feels arbitrary until you see what the alternative costs. #abc is #aabbcc. Doubling maps the 4-bit range onto the 8-bit one evenly: 0 becomes 00 at the bottom, f becomes ff at the top, both ends land where they should. If it padded instead, f would max out at f0, or 240, and pure white would be unreachable in shorthand. Which would make it useless for the single thing it gets used for most.
The tradeoff is reach. Shorthand only addresses 16³, so 4,096 colors out of the 16.7 million, and every one of them has matching digits inside each pair. A shorthand hex is never subtle. It’s the 16-crayon box.
And the most quietly affecting thing in the entire CSS spec runs on that mechanic. rebeccapurple is #663399, which is #639. Eric Meyer’s daughter Rebecca died of brain cancer on her sixth birthday in 2014, purple was her color, and the working group added a named color, a thing that essentially never happens. Six, three, nine. I only type a shorthand hex maybe once a month and I think about it every time, and it’s a strange and good thing for a specification to be capable of.
The fourth pair is alpha, and 80 is not “50%”
#RRGGBBAA has been in all three engines for years now, and it’s handy for the case where you want one token instead of a whole rgb() call. The fourth pair reads exactly like the other three: 00 fully transparent, ff fully opaque, 256 steps in between.
The part that bites is that those steps are 0 to 255, not 0 to 100, so the percentage you want almost never lands on a round hex value.
/* the ones I actually keep in my head */
1a /* 10%, near enough */
33 /* 20% */
80 /* 50.2%, and nobody has ever noticed the 0.2 */
cc /* 80% */
e6 /* 90% */
What you can read off a hex code, and what you can’t
You can eyeball a hex faster than you’d think, because the first digit of each pair is the coarse value and the second is fine-tuning you’re allowed to ignore. #3a7bd5 is 3, then 7, then d. Low red, middling green, high blue. A blue, softened by the fact that the other two channels aren’t near zero. That’s most of what you need before you’ve opened anything.
Equal pairs means gray, always. #333, #888, #e5e5e5 are all sitting on the gray axis because no channel is louder than another. And the gap between the largest pair and the smallest is roughly how saturated it is, while the smallest pair on its own is roughly how much white got mixed in. So #3a7bd5 (spread from 3a to d5, fairly wide, floor not that low) reads as saturated but not fluorescent, and it is.
It’s all case-insensitive, by the way. #FFF and #fff are the same token to the parser, so pick one, put it in the formatter, and never discuss it again.
Hex is a great way to write a color down and a bad way to change one.
Here’s the part you can’t read, though, and it’s the reason every other format in this post exists. You cannot do arithmetic on it. “Make this 10% lighter” is not an operation any human performs on #3a7bd5 at a keyboard, because all three channels have to move and they don’t move by the same amount, and if you get the ratios wrong you don’t get a lighter blue, you get a slightly different blue. Which you’ll notice next to the original and won’t be able to name. Hex is a great way to write a color down and a bad way to change one.
(The other legacy showing through: 00, 33, 66, 99, cc, ff, six values per channel, 216 colors, the web-safe palette from when monitors did 256 colors total. That constraint stopped existing around 20 years ago and I still reach for #333 by reflex, so, you know. Habits outlive their reasons.)
The rest of the formats, quickly
rgb() is the same three numbers in decimal, which sounds like a lateral move and mostly is. The modern syntax drops the commas and puts alpha behind a slash: rgb(255 0 0 / 50%). Commas still parse, and rgba() is now just an alias for rgb() (same with hsla()), so there’s nothing to migrate. Where decimal earns its place is when the numbers are coming from somewhere rather than being typed: a custom property, a calc(), a token pipeline.
hsl() is hue in degrees, saturation and lightness in percent, and it’s the first format you can actually reason about by hand. 0 is red, 120 green, 240 blue. Hold the hue, walk the lightness, you have a ramp. The catch, and it’s a real one, is that its lightness isn’t perceptual: hsl(60 100% 50%) and hsl(240 100% 50%) both claim 50% and one of them is yellow and one is navy-adjacent and they are not remotely the same brightness. So the palette you generate by holding L constant across hues comes out lumpy, and you end up hand-correcting it, which defeats the point.
hwb() is hue, whiteness, blackness, closer to how mixing paint feels. Same hue wheel as HSL, so it inherits the same unevenness. I’ve never once seen it in a codebase I didn’t write.
And the named colors, about 150 of them, inherited from X11 and gloriously inconsistent: darkgray (#a9a9a9) is lighter than gray (#808080), which is true, and which I love. They’re bad for design and excellent for debugging, since nobody is ever going to mistake outline: 2px solid hotpink for an intentional style.
oklch is the one worth learning
oklch(70% 0.15 250) is perceptual lightness, chroma, hue angle. The L is built against how eyes actually work, so two colors at the same L look equally light, which means a ramp you generate by walking L looks like a ramp instead of like six unrelated decisions. Gradients through it don’t go muddy in the middle. It reaches outside sRGB into P3, so it can express colors hex structurally cannot. All three engines have it now.
But honestly the perceptual stuff isn’t why I moved. This is:
:root {
--brand: oklch(55% 0.18 264);
}
.button:hover {
/* same hue, same chroma, 8 points lighter, nothing else drifts */
background-color: oklch(from var(--brand) calc(l + 0.08) c h);
}
.button--ghost {
background-color: color-mix(in oklch, var(--brand) 12%, white);
}
Relative color syntax, that from keyword, plus color-mix(). Safari and Chrome have the relative syntax, Firefox is still catching up, so it’s newer than I’d like but it’s arriving. And what it means is that a color is a value you can compute with in the stylesheet, rather than a literal you fetch from a picker and paste. Hover, disabled, the tint behind the badge, the dark mode variant: one expression each, all of them anchored to one token, all of them updating when that token does.
So what do you actually type
Hex isn’t going anywhere and shouldn’t. It’s the interchange format. It’s what Figma hands you, what a client pastes into an email, what survives a Slack message intact, and it’s one unbroken token with no spaces in it. For a flat literal that will never change, it’s completely fine, and I’m not going to pretend #fff needs to be oklch(100% 0 0).
The rough rule I’ve landed on, which took me way longer than it should have: hex for colors I’m copying, oklch for colors I’m deriving. If somebody handed me the value, it goes in as hex. If I know I’m going to need a hover state and a disabled state and a subtle tint of it, it goes in as oklch, because then those are three lines of arithmetic instead of three more trips to a color picker and three more magic numbers nobody can regenerate in a year.
The actionable parts are that #f0c doubles rather than pads, 80 is 50.2%, and #0000 isn’t black. The rest you can look up. I do still type #333 from muscle memory, roughly daily, so treat the philosophy accordingly.