HEX is fine for storage, but it is a poor authoring format. Learn how OKLCH fixes HSL’s lightness problems and makes palettes easier to tune.

HEX is great for storage and terrible for authoring. HSL looked like the humane alternative, but its “lightness” falls apart the moment you build a real palette across multiple hues. OKLCH is the first CSS color format that’s readable, perceptually consistent, and baseline across modern browsers since 2023.
Two things usually push developers toward oklch color css: color tokens that are annoying to edit, and ramps that look mathematically correct but visually wrong.
If your brand color is #3498db, you can’t read that value and know what to change. Is it too dark? Too saturated? Slightly too cyan? You need a picker, a converter, or trial and error.
Compare that with OKLCH:
/* oklch(lightness chroma hue) */
oklch(0.62 0.16 250)
/* ^ ^ ^
| | hue angle: 250 = blue (0-360)
| chroma: 0.16 = moderately vivid (0 = gray, ~0.4 max)
lightness: 0.62 = medium-light (0 = black, 1 = white) */That value is readable at a glance. You know it’s a medium-light blue with moderate intensity. If you want it darker, lower L. If you want it more vivid, raise C. If you want it more purple, rotate H.
HEX still has a place. It’s fine as an interchange or storage format. The issue is authoring: #3498db is not a design control surface.
This is the bigger problem, and it’s why HSL-generated palettes often look uneven or fail contrast checks even when the numbers seem tidy.
The classic example:
/* These claim to be the same lightness in HSL */
--green: hsl(120 60% 50%); /* looks bright */
--blue: hsl(240 60% 50%); /* looks darker */
--red: hsl(0 60% 50%); /* looks different again */
/* In OKLCH, same L = actually equal perceived brightness */
--green: oklch(0.65 0.20 145);
--blue: oklch(0.65 0.20 265);
--red: oklch(0.65 0.20 25);If you’ve ever generated semantic colors like success, info, warning, and danger using one HSL formula, you’ve seen this. The green ends up looking louder than the blue. The red may look heavier than both. Then you start compensating hue by hue, which defeats the whole point of a token system.
That’s the practical reason to switch: in OKLCH, the same L actually looks like the same brightness across hues.
Here’s the useful mental model: OKLCH gives you HSL-like readability without HSL’s fake lightness. You don’t need a color theory detour. You need a repeatable way to author tokens.
You only need three controls:
L: perceptual lightness, 0 to 1C: chroma, or how vivid the color isH: hue angle, 0 to 360The key behavior is that equal L values stay visually balanced across hue shifts.
Use the same lightness and chroma for three very different hues:
:root {
--red-balanced: oklch(0.65 0.20 25);
--green-balanced: oklch(0.65 0.20 145);
--blue-balanced: oklch(0.65 0.20 265);
}
.red {
background: var(--red-balanced);
}
.green {
background: var(--green-balanced);
}
.blue {
background: var(--blue-balanced);
}That’s the proof HSL can’t give you. Same L, same perceived brightness. Same C, similar visual intensity. Change hue without rebuilding the whole scale.
A practical migration starts with one palette, not every color in your app. Take a typical token file authored in HEX:
:root {
--brand-100: #eaf3ff;
--brand-300: #93c5fd;
--brand-500: #3b82f6;
--brand-700: #1d4ed8;
--brand-900: #172554;
}This works, but the numbers don’t tell you why the steps look the way they do. If --brand-700 feels too heavy, there’s no direct parameter to tweak.
A lot of teams tried to “fix” that with HSL:
:root {
--brand-100: hsl(250 80% 95%);
--brand-300: hsl(250 80% 78%);
--brand-500: hsl(250 80% 62%);
--brand-700: hsl(250 80% 44%);
--brand-900: hsl(250 80% 26%);
}It looks cleaner, but the visual spacing still tends to bunch up or spread out unpredictably. The hue might feel too electric at midtones and too dull in shadows. Equal numeric jumps don’t give equal visual jumps.
Now compare that with an OKLCH ramp:
:root {
--brand-100: oklch(0.95 0.05 250);
--brand-300: oklch(0.80 0.12 250);
--brand-500: oklch(0.62 0.20 250); /* base */
--brand-700: oklch(0.44 0.18 250);
--brand-900: oklch(0.26 0.10 250);
}This is the same idea as a 100–900 token scale, but now the lightness steps are doing what you expect. 100 is clearly lighter than 300, 500 feels like the true base, and the darker steps don’t collapse into a muddy block.
If you already have brand colors in HEX, convert them first with the Color Converter. That gives you a starting OKLCH value you can tune instead of rebuilding everything from scratch.
This is the “aha” moment for most developers.
In HSL or HEX workflows, hover states often end up as a hand-tuned mess. One color darkens nicely, another gets muddy, another looks oversaturated, and somebody reaches for filter: brightness() because it’s faster.
With OKLCH, you can darken by reducing only L:
.button {
background: oklch(0.62 0.20 250);
}
.button:hover {
/* Darken by 0.10 — works for ANY hue */
background: oklch(0.52 0.20 250);
}
/* Compare the HSL equivalent — unpredictable across hues:
.button:hover { filter: brightness(0.85); } /* had to use filter instead */That pattern scales. You can define interaction states as transformations:
L - 0.08L - 0.12L + 0.20, C - 0.08Because L is perceptual, those adjustments hold up across blue, green, red, amber, or purple. You stop tuning every hue separately.
Once you trust L, you can stop special-casing every status color.
A simple pattern is to set one lightness target for all semantic colors used on white surfaces:
:root {
--success-600: oklch(0.45 0.16 145);
--info-600: oklch(0.45 0.16 245);
--warning-600: oklch(0.45 0.16 85);
--danger-600: oklch(0.45 0.16 25);
}
.badge-success { color: var(--success-600); }
.badge-info { color: var(--info-600); }
.badge-warning { color: var(--warning-600); }
.badge-danger { color: var(--danger-600); }The practical win is consistency. Instead of asking “why is warning louder than danger?”, you start from a shared lightness and chroma rule, then adjust only where needed.
This doesn’t mean you never verify contrast. It means your starting point is much more reliable than “all semantic colors use hsl(hue 60% 50%).”
There’s a second major reason OKLCH matters: P3.
HEX, RGB, and HSL are tied to sRGB. That’s a smaller color space than what many modern displays can show. Current iPhones, MacBooks, and a lot of OLED panels support Display-P3, which gives you a wider range of vivid colors.
OKLCH can express those colors directly.
:root {
--srgb-blue: oklch(0.62 0.20 250);
--p3-blue: oklch(0.62 0.31 250);
}
.card {
background: var(--srgb-blue);
}
@supports (color: oklch(0.62 0.31 250)) {
.card--vivid {
background: var(--p3-blue);
}
}On a wide-gamut display, that higher-chroma blue can look noticeably richer. On a non-P3 or sRGB-only display, browsers clip it gracefully to the nearest displayable sRGB value. That’s the important part: this is not risky progressive enhancement. You don’t break older screens; they just see the nearest in-range color.
This also affects gradients. Interpolating in the wrong color space often creates muddy midpoints. Interpolating in OKLCH keeps the transition vivid.
/* Goes muddy gray through the middle in sRGB */
background: linear-gradient(to right, oklch(0.55 0.22 264), oklch(0.65 0.24 29));
/* Stays vivid — routes through oklch color space */
background: linear-gradient(in oklch to right, oklch(0.55 0.22 264), oklch(0.65 0.24 29));If you’re converting an existing HEX palette before trying this, use the Color Converter to get the OKLCH equivalents first. That makes it much easier to see whether your current colors are conservative sRGB values or candidates for wider-gamut chroma.
Tailwind 4 switching to OKLCH by default is a useful signal here. This isn’t an experimental side path anymore. It’s becoming the normal way to author color tokens in CSS.
OKLCH is better for authoring, but there are a few things to watch for.
First, some L + C + H combinations don’t fit inside sRGB. That usually shows up when you push chroma too high at very light or very dark values. Browsers handle this by clipping to the nearest in-range color, which is safe, but you should still check the result on an sRGB display if your palette needs to look tightly controlled across devices.
Second, not every hue can sustain the same maximum chroma at the same lightness. A vivid yellow and a vivid blue do not have identical usable ceilings. In practice, this means “keep C constant everywhere” is a good starting rule, not a law. You’ll still sometimes trim chroma per hue.
Third, OKLCH does not mean HEX disappears. If your design tokens are compiled, exported to other platforms, or stored in JSON, you may still output HEX downstream. The better workflow is to author in OKLCH, keep the values human-tunable, and let tooling serialize to whatever format another system expects.
Finally, migration is easiest when done incrementally. Start with brand tokens, then semantic colors, then gradients and interaction states. If you try to replace every legacy color in one pass, the review process gets noisy and people blame the format instead of the migration strategy.
HEX got popular because it was compact, not because it was a good authoring model. If your current palette is full of values you can’t read and HSL ramps you can’t trust, OKLCH fixes both problems with one move: readable channels and real perceptual lightness. Browser support is baseline, Tailwind 4 already treats it as the default, and the fastest way to feel the difference is to convert your brand color with the Color Converter, then rebuild your 100 to 900 tokens from there.