← All writing
Craft · · 11 min

10% lighter

On what the six digits in a hex code actually are, why the shorthand doubles instead of padding, and the format where lighter is finally an instruction a browser can follow.

CSS

A designer left a note on a staging link about one button. The hover was supposed to be the brand blue, 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 cannot unsee purple.

I’d made that hover the way I make all of them, which is open a color picker, drag the little circle around until it looks about right, copy the hex out 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, and the hue was identical. 215 in the resting color, 215 in the hover. Same hue, by the only definition my tools had, and one of them looks purple.

So I went and learned what the six digits actually are, which is 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 a pair of digits in base 16, each pair running 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, so 256 values, 00 through ff, which 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, 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 came out of 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. 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, and the doubling looks arbitrary until you figure out what padding would cost. #abc is #aabbcc. 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 and both ends land where they should. Pad instead and f maxes out at f0, which is 240, and 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, so 4096 colors out of the 16.7 million, all of them with 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 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 added a named color, which is a thing that essentially never happens. 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 useful 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.

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. Those five are the ones I keep in my head and I look up everything else.

overlay.css
/* 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%);
}
CSS

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. Low red, middling green, high blue. So, a blue, softened by the fact that 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, and the smallest pair on its own is roughly how much white got mixed in.

It’s all case insensitive too. Six one way, half dozen the other since #FFF and #fff are the same token to the parser. So pick one, put it in the formatter and never discuss it again.

(One more piece of history is showing through in there. 00, 33, 66, 99, cc, ff, 6 values per channel, 216 colors, the web-safe palette from when monitors did 256 colors total. 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 channels, they’re independent, and none of them knows what the other two are doing. “Lighter” means all three have to move, they have to move by different amounts, and the amounts 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, saturation and lightness in percent. It’s the first format you can reason about by hand. 0 is red, 120 green, 240 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, and 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 of them is yellow and one is close to navy. They are 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. It’s why a 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, 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, blackness, which is closer to how mixing paint feels. 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 the named colors, about 150 of them, inherited from X11 and gloriously inconsistent. darkgray (#a9a9a9) is lighter than gray (#808080) 😐 That’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, 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 a handful of unrelated decisions. That’s perceptual lightness and not a contrast ratio, which measures something else and lies in its own way. So you’re still checking your pairs by hand. Gradients through it 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. Being able to write this moved me.

button.css
: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);
}
CSS

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 not yet. So it’s newer than I’d like and 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, the dark mode variant, one expression each, all anchored to one token, all of them updating 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.

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. It’s one unbroken token with no spaces in it. For a flat literal that will never change it’s completely fine. 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 and a disabled and a subtle tint of it, it goes in as oklch, because 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 and 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, because 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.

Read similar posts
2 min

light-dark()

Two color values in one declaration, picked by the color scheme, which removes most of the reason a theme needed a second block of custom properties at all.

9 min

Style queries

A card with 5 surface variants and 4 children that each had to know which one they were sitting in, and 20 selectors across 4 files holding that together until a feature I'd been ignoring since 2024 deleted all of them.