10% lighter
Hex codes are easy to read once you know what the six digits are and no help at all when a color needs to be 10% lighter.
The client’s designer left a note on a staging link about one button. The hover was supposed to be the brand blue but 10% lighter, and to them it read as purple.
They were right. I put the two swatches next to each other at button size, and once somebody has said purple you can’t unsee purple.
I’d made that hover the way I make all of them. I open a color picker, drag the little circle around until it looks about right, copy the hex and paste it into the stylesheet. Very scientific.
Checking it afterward stuck with me. I ran both values through hsl() expecting to find I’d fat-fingered the hue. The hue was identical, 215 in the resting color and 215 in the hover. By the only definition my tools had, that’s the same hue. And one of them looks purple.
So I went and learned what the six digits actually are. It’s an evening’s work and then you have it for good.
Why not just use decimal?
A hex code is three numbers wearing a disguise. #RRGGBB is red, green and blue. Each one is a pair of digits in base 16, and each pair runs from 00 to ff.
Base 16 means one digit holds 16 values, 0 through 9 and then a through f. So a is 10 and f is 15. A pair gets you 16 × 16 = 256 values, so 00 through ff is 0 through 255. And that range is exactly one byte.
That’s the whole reason it’s hex instead of something friendlier. A color channel is stored in a byte, and a byte is always exactly two hex digits. There’s no padding and no variable width to reason about.
Decimal can’t do that because a decimal channel is anywhere from one to three characters wide. So you’d need separators between them. Hex lines up with the storage so exactly that the notation is a transcription of the bytes. That’s how it went from X11 into HTML’s bgcolor and never left. 6 digits, 3 bytes, 16.7 million combinations, which is the number printed on the side of every monitor box in the 90s.
Seeing double
#f0c is #ff00cc and #abc is #aabbcc. Each digit gets repeated. It isn’t padded with a zero and it isn’t truncated.
I think this one catches people because “shorthand” sounds like it should be lossy in the obvious direction. The doubling looks arbitrary until you figure out what padding would cost.
Doubling maps the 4-bit range onto the 8-bit one evenly, so 0 becomes 00 at the bottom and f becomes ff at the top. Both ends land where they should. Pad instead and f maxes out at f0, which is 240. Pure white becomes unreachable in shorthand, which would make it useless for the one thing it gets used for most.
The tradeoff is reach. Shorthand only addresses 16 to the third, which is 4096 colors out of the 16.7 million. Every one of them has matching digits inside each pair, so a shorthand hex is never subtle. It’s the 16-crayon box.
And the most affecting thing in the whole spec runs on that mechanic. rebeccapurple is #663399, which is #639.
Eric Meyer’s daughter Rebecca died of brain cancer on her sixth birthday. That was 2014. Purple was her color, and the working group did something it essentially never does and added a named color.
Six, three, nine. I type a shorthand hex maybe once a month and I think about it every time.
80 is not half
#RRGGBBAA has been in all three engines for years. It’s handy when you want one token instead of a whole rgb() call. The fourth pair reads exactly like the other three: 00 is fully transparent, ff is fully opaque and there are 256 steps in between.
Those steps run 0 to 255 instead of 0 to 100, so the percentage you want almost never lands on a round hex value. 1a is near enough to 10%, 33 is 20%, cc is 80%, e6 is 90%, and 80 is 50.2%, which nobody has ever noticed. I keep those five in my head and look up everything else.
/* black at half, or as close as a byte gets to half */
.scrim {
background-color: #00000080;
}
/* the same thing, said out loud */
.scrim {
background-color: rgb(0 0 0 / 50%);
}
What six digits will tell you
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. That’s low red, middling green and high blue. So it’s a blue, softened because the other two channels aren’t near zero.
Equal pairs means gray, always. #333, #888 and #e5e5e5 are all sitting on the gray axis because no channel is louder than another. The gap between the largest pair and the smallest is roughly how saturated it is. The smallest pair on its own is roughly how much white got mixed in.
It’s all case insensitive too. #FFF and #fff are the same token to the parser, so it’s six one way, half dozen the other. Pick one, put it in the formatter and never discuss it again.
(One more piece of history shows through in there. 00, 33, 66, 99, cc and ff are the web-safe palette from when monitors did 256 colors total, 6 values per channel and 216 colors. That constraint stopped mattering about 20 years ago and I still reach for #333 by reflex, so, y’know. Habits outlive their reasons.)
So how do you make it lighter?
You don’t, and that’s the part the button taught me.
There’s no lightness in a hex code. There are three independent channels and none of them knows what the other two are doing. “Lighter” means all three have to move by different amounts that depend on where each one started.
Get the ratios wrong and 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.
50% of what?
hsl() is hue in degrees, then saturation and lightness in percent. It’s the first format you can reason about by hand. A hue of 0 is red, 120 is green and 240 is blue. Hold the hue, walk the lightness, and you have a ramp.
Which is what I thought I had. But HSL’s lightness isn’t perceptual and neither is its hue. Both of those are a bigger deal than the docs make them sound.
hsl(60 100% 50%) and hsl(240 100% 50%) both claim 50%. One is yellow and the other is close to navy. They’re nowhere near 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 of generating it.
The hue number has the same problem. It’s a formula over the same three sRGB channels, so it can hold perfectly still while the color a person actually sees walks off somewhere. Blue is the worst case for it.
Lighten a blue in sRGB and it drifts toward violet, which is why sRGB gradients from blue to white go purple in the middle. And it’s why the designer looking at my hover saw purple while my tooling told me the hue hadn’t moved a degree. My tooling wasn’t lying to me. It was answering a different question from the one being asked.
The rest of the family
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, like rgb(255 0 0 / 50%). Commas still parse and rgba() is now an alias for rgb() (same with hsla()). So there’s nothing to migrate. Decimal earns its keep when the numbers are coming from somewhere instead of being typed, like a custom property, a calc(), or a token pipeline.
hwb() is hue, whiteness and blackness, which is closer to how mixing paint feels. It uses the same hue wheel as HSL, so it inherits all the same unevenness. I’ve never once seen it in a codebase I didn’t write.
And then there are the named colors, about 150 of them, inherited from X11 and gloriously inconsistent. darkgray (#a9a9a9) is lighter than gray (#808080) 😐 It’s true, and I love it.
They’re bad for design and excellent for debugging because nobody is ever going to mistake outline: 2px solid hotpink for an intentional style.
Where lighter means lighter
oklch(70% 0.15 250) is perceptual lightness, chroma and hue angle. The L is built against how eyes actually work, so two colors at the same L look equally light. A ramp you generate by walking L looks like a ramp instead of like a handful of unrelated decisions.
Perceptual lightness isn’t a contrast ratio, though. That measures something else and lies in its own way. So you’re still checking your pairs by hand.
Gradients through oklch don’t go muddy or purple in the middle either. And it reaches outside sRGB into P3, so it can express colors hex structurally cannot. All three engines have it.
I’m sure a color scientist would put every bit of that better and I’d be curious to hear it. But honestly, the perceptual stuff isn’t why I moved over. This is.
:root {
--brand: oklch(55% 0.18 264);
}
/* same hue, same chroma, 8 points lighter, nothing else moves */
.button:hover {
background-color: oklch(from var(--brand) calc(l + 0.08) c h);
}
.button--ghost {
background-color: color-mix(in oklch, var(--brand) 12%, white);
}
That’s relative color syntax (the from keyword) plus color-mix(). Safari has had the relative syntax for a while and Chrome picked it up this fall. Firefox doesn’t have it yet. So it’s newer than I’d like, but it is arriving.
It means a color becomes a value you can compute with in the stylesheet instead of a literal you fetch from a picker and paste. Hover, disabled, the tint behind a badge and the dark mode variant are one expression each. They’re all anchored to one token and they all update when that token does.
And “8 points lighter, nothing else moves” is a thing I can put in a code review and a thing the next person can read. “I dragged a circle around a picker until it looked right” is neither.
What do you actually type, then?
Hex isn’t going anywhere and shouldn’t. It’s the interchange format. It’s what Figma hands you and what a client pastes into an email. It survives a Slack message intact because it’s one unbroken token with no spaces in it.
For a flat literal that will never change, hex is completely fine. I’m not going to pretend #fff needs to be oklch(100% 0 0).
My rough rule is hex for colors I’m copying and oklch for colors I’m deriving. It took me way longer than it should have to land on that.
If somebody hands me the value, it goes in as hex. If I know I’m going to need a hover and a disabled and a subtle tint of it, it goes in as oklch. Then those are three lines of math instead of three more trips to a picker and three more magic numbers nobody can regenerate in a year.
I redid that button’s hover as one line of oklch. The designer’s reply was “yeah, that’s it,” which is about as much validation as any of this ever gets. The blue itself is still #3a7bd5 in the token. That’s the value they handed me and I have no business improving on it.
Everything downstream of it is math now. I’d love to tell you I’ve been back through every palette I maintain and done the same, but I’ve done it to exactly one.