Why OKLCH Is Replacing Hex and HSL in Modern CSS Color Systems
For most of the web’s history, a colour in CSS was a hex code. Designers picked it in a tool, developers pasted it into a stylesheet, and nobody asked what the six characters meant. When teams needed variations, such as a hover state, a disabled tint or a dark-mode equivalent, they either picked new hex codes by eye or reached for HSL, which at least looked readable. hsl(220 90% 50%) says something about hue, saturation and lightness in a way #0d59f2 never will.
That arrangement is now changing. Since 2023 every major browser has supported oklch(), a colour function defined in CSS Color Module Level 4. Tailwind CSS moved its entire default palette from rgb to oklch in version 4.0, released in January 2025. Design-system teams increasingly describe their scales in OKLCH and derive variants with color-mix() and relative colour syntax rather than hand-picking dozens of hex values.
The usual explanation is that OKLCH is “perceptually uniform” and supports “wide-gamut colours”. Both claims are true, but they are often repeated without explaining what they mean in a stylesheet, where they stop being true, and what new problems they introduce. This article covers the mechanism, the measurable differences, and the trade-offs that matter when you build a colour system on it.
The problem HSL was supposed to solve, and didn’t
HSL was introduced to CSS because RGB and hex values are hard to reason about. Changing the lightness of #0d59f2 means recalculating three channels. In HSL you change one number. The catch is that the “L” in HSL is not lightness as human vision perceives it. It is a geometric property of the RGB cube: HSL is a reshaped view of the same sRGB values, not a model of perception.
The difference is easy to measure. Pure yellow, pure green, pure red and pure blue all have exactly the same HSL lightness, 50 percent. Yet they look nothing alike in brightness, and the accessibility formula used by WCAG 2 agrees with your eyes, not with HSL.
html
<table>
<thead>
<tr>
<th>Colour</th>
<th>HSL lightness</th>
<th>OKLCH lightness</th>
<th>WCAG 2 contrast against white</th>
</tr>
</thead>
<tbody>
<tr>
<td>Yellow, #ffff00</td>
<td>50%</td>
<td>0.968</td>
<td>1.07 to 1</td>
</tr>
<tr>
<td>Green, #00ff00</td>
<td>50%</td>
<td>0.866</td>
<td>1.37 to 1</td>
</tr>
<tr>
<td>Red, #ff0000</td>
<td>50%</td>
<td>0.628</td>
<td>4.00 to 1</td>
</tr>
<tr>
<td>Blue, #0000ff</td>
<td>50%</td>
<td>0.452</td>
<td>8.59 to 1</td>
</tr>
</tbody>
</table>The values in the table are calculated with the standard sRGB-to-OKLab conversion and the WCAG 2 relative luminance formula. Four colours with identical HSL lightness span almost the entire contrast range against a white background, from unreadable to comfortably accessible.
This is why HSL-based palettes need so much manual correction. A design system that generates its “500” shade for every hue by setting hsl(H 80% 50%) gets a yellow that is nearly invisible on white and a blue that is almost navy. Every hue has to be tuned by hand, and the tuning breaks whenever someone adds a new hue or adjusts the formula.
What OKLCH actually is
OKLCH is the cylindrical form of OKLab, a colour space published by Björn Ottosson in December 2020. OKLab was designed for one job: predicting how humans perceive lightness, chroma and hue more accurately than earlier models, while staying simple to compute. Ottosson fitted it to established colour-appearance data and specifically set out to fix known problems in the older CIELAB model, whose blues shift noticeably in hue as their lightness or saturation changes.
OKLCH expresses the same space with three intuitive coordinates.
L, lightness. From 0, black, to 1 or 100%, white. It aims to track perceived lightness, so equal steps should look like roughly equal steps.
C, chroma. How far the colour is from grey. 0 is fully neutral. There is no fixed maximum, but colours that real screens can show stay below about 0.4, and CSS maps 100% chroma to 0.4.
H, hue. An angle from 0 to 360 degrees. Unlike HSL, hue in OKLCH is designed to stay perceptually stable as lightness and chroma change.
A colour in CSS looks like this:
css
.button {
background-color: oklch(0.62 0.19 260);
}That is a mid-lightness, fairly saturated blue. Change the first number and you change perceived lightness. Change the second and the colour gets greyer or more vivid. Change the third and you rotate the hue while, in principle, keeping lightness and chroma constant.
CSS Color 4 also added the rectangular form, oklab(), as well as lab(), lch() and the color() function for named spaces such as display-p3. MDN marks oklch() as Baseline “widely available”, working across major browsers since May 2023.
Why lightness you can trust changes how palettes are built
The practical consequence of a more perceptual lightness axis is that palette steps become predictable across hues. If a design system defines its scale by OKLCH lightness, with, say, the “600” shade at 0.55 for every hue, the resulting colours have much more similar perceived brightness and much more similar contrast than HSL would give.
This can be checked numerically. The table below holds OKLCH lightness at 0.55 and chroma at 0.09, small enough to stay inside sRGB for every hue tested, and varies only the hue.
html
<table>
<thead>
<tr>
<th>Hue angle</th>
<th>Approximate colour</th>
<th>Resulting hex</th>
<th>WCAG 2 contrast against white</th>
</tr>
</thead>
<tbody>
<tr>
<td>25</td>
<td>Muted red</td>
<td>#a05c57</td>
<td>5.07 to 1</td>
</tr>
<tr>
<td>70</td>
<td>Brown-orange</td>
<td>#936831</td>
<td>4.94 to 1</td>
</tr>
<tr>
<td>110</td>
<td>Olive</td>
<td>#757633</td>
<td>4.78 to 1</td>
</tr>
<tr>
<td>145</td>
<td>Green</td>
<td>#4e7f50</td>
<td>4.66 to 1</td>
</tr>
<tr>
<td>195</td>
<td>Teal</td>
<td>#138282</td>
<td>4.64 to 1</td>
</tr>
<tr>
<td>265</td>
<td>Blue</td>
<td>#5870a6</td>
<td>4.89 to 1</td>
</tr>
<tr>
<td>330</td>
<td>Purple-magenta</td>
<td>#8f5e8b</td>
<td>5.08 to 1</td>
</tr>
</tbody>
</table>Contrast varies between about 4.6 and 5.1 to 1 across the whole wheel. Compare that with HSL’s range from 1.07 to 8.59 at a nominally identical lightness. For a design system, this is the core benefit: a lightness token means approximately the same thing whatever the hue.
The same numbers also show the limit. OKLCH lightness is not WCAG contrast. WCAG 2 defines contrast using the relative luminance of sRGB colours, a different formula from OKLab’s lightness. At the same OKLCH lightness the contrast still drifts by about half a point. That drift is enough to push a colour just below the 4.5 to 1 threshold that WCAG 2 level AA requires for normal-sized text. OKLCH makes accessible palettes far easier to design, but it doesn’t replace actually checking the contrast of the final colour pairs.
The chroma ceiling nobody mentions
The most common surprise for teams moving to OKLCH is that chroma doesn’t behave like HSL saturation. In HSL, 100% saturation is always available. In OKLCH, the amount of chroma a screen can actually display depends heavily on both hue and lightness.
The reason is physical. A display can only produce colours by mixing its red, green and blue primaries. A very light yellow can be highly saturated, because yellow is produced by driving the red and green channels at full strength. A very dark yellow can’t be, because darkness and yellow saturation pull in opposite directions; that colour is olive. Blues behave the opposite way: they reach their most intense chroma at lower lightness.
Calculated for sRGB, the maximum chroma at lightness 0.55 ranges from about 0.094 for teal-cyan hues to about 0.25 for blue and magenta hues, more than two and a half times as much. At lightness 0.7 the ordering changes again.
html
<table>
<thead>
<tr>
<th>Hue angle</th>
<th>Maximum sRGB chroma at L 0.55</th>
<th>Maximum sRGB chroma at L 0.70</th>
</tr>
</thead>
<tbody>
<tr>
<td>25, red</td>
<td>about 0.22</td>
<td>about 0.19</td>
</tr>
<tr>
<td>70, orange</td>
<td>about 0.12</td>
<td>about 0.15</td>
</tr>
<tr>
<td>110, yellow-olive</td>
<td>about 0.12</td>
<td>about 0.15</td>
</tr>
<tr>
<td>145, green</td>
<td>about 0.17</td>
<td>about 0.22</td>
</tr>
<tr>
<td>195, teal-cyan</td>
<td>about 0.09</td>
<td>about 0.12</td>
</tr>
<tr>
<td>265, blue</td>
<td>about 0.25</td>
<td>about 0.16</td>
</tr>
<tr>
<td>330, magenta</td>
<td>about 0.25</td>
<td>about 0.31</td>
</tr>
</tbody>
</table>This has a direct consequence for colour systems. A palette that sets the same chroma for every hue, say 0.2 across the board, will specify colours that sRGB screens can’t display for some hues and lightness levels. What happens then depends on gamut mapping, the least understood part of the whole topic.
Gamut mapping, where theory and browsers diverge
A colour outside the display’s gamut must be converted to something the screen can show. CSS Color 4 describes a gamut-mapping approach that works in OKLCH: keep lightness and hue as stable as possible and reduce chroma until the colour fits. In other words, show the most saturated version of the same colour that the screen can produce.
Browsers, however, have historically not done this. In a 2023 discussion in the CSS Working Group’s issue tracker, contributors documented that browsers used naive clipping instead. They convert the colour to the display’s RGB space and clamp any channel below 0 or above 1. Clipping is fast, but it doesn’t preserve lightness or hue.
The effect is easy to demonstrate. Take oklch(0.7 0.25 195), a vivid teal that is far outside sRGB. Converted to sRGB and clipped channel by channel, it becomes approximately #00c8ce. Measured back in OKLCH, that colour is roughly oklch(0.755 0.128 199). Lightness has risen by more than 0.05, hue has shifted by about 4 degrees, and chroma has halved. A palette relying on precise OKLCH lightness steps loses that precision exactly where the colours are most vivid.
The standard is moving. In August 2025 the CSS Working Group resolved to define two additional, cheaper gamut-mapping algorithms for OKLab and OKLCH that approximate the specification’s original method, and to require implementations to support at least one of the three. Agreeing on a specification is not the same as shipping it, though. Until browsers implement it, the safe assumption is that out-of-gamut colours will be clipped.
Practical consequences follow:
Keep production tokens inside the gamut you target. For most interfaces that still means sRGB, with optional wide-gamut enhancements.
Use tooling that generates sRGB fallbacks with proper gamut mapping rather than relying on the browser. Several PostCSS plugins and colour libraries already do this at build time.
Check vivid colours on both standard and wide-gamut screens. A swatch that looks right on a modern laptop can look shifted on an older monitor.
Wide gamut, and why it matters more than it used to
Hex codes, rgb() and hsl() can only express colours inside sRGB, the standard defined in the 1990s for the monitors of that era. Many current phones, laptops and monitors can display a wider range, typically the Display P3 gamut, which extends especially into more saturated reds, oranges and greens.
OKLCH isn’t tied to any gamut. It describes colours by perception, so it can specify colours that only wide-gamut screens can display. This is what Tailwind’s v4 announcement means when it describes a “modernized P3 color palette”. By moving to OKLCH, its default colours can be more vivid on screens that support it.
CSS offers a media query to target wide-gamut displays explicitly.
css
:root {
--brand: oklch(0.62 0.17 145);
}
@media (color-gamut: p3) {
:root {
--brand: oklch(0.62 0.24 145);
}
}Here the brand green gets more chroma only on screens that can show it, and stays inside sRGB elsewhere. This pattern avoids relying on the browser’s gamut mapping altogether.
One caveat deserves a mention. WCAG 2’s contrast formula was defined for sRGB. For colours outside sRGB, contrast tools may give inconsistent results depending on how they convert the colour. For text and essential interface elements, keeping the colour pair inside sRGB remains the most predictable approach.
Interpolation, where OKLab quietly improves gradients and mixes
Gradients and colour mixing in CSS involve interpolation, calculating the colours in between two endpoints. The colour space used for that calculation changes the result dramatically.
Interpolating in gamma-encoded sRGB, the traditional default for gradients, tends to produce muddy midpoints. A gradient from blue to yellow passes through a dull grey because the RGB channels average toward the middle. Interpolating in OKLab keeps the transition perceptually even and avoids the grey dip in most cases.
CSS now lets authors choose the interpolation space for both gradients and colour mixing.
css
.hero {
background: linear-gradient(in oklab, oklch(0.6 0.2 260), oklch(0.9 0.18 100));
}
.button:hover {
background-color: color-mix(in oklab, var(--brand) 85%, black);
}According to MDN, color-mix() has been available across major browsers since May 2023. When no colour space is specified, it uses oklab. When a polar space such as oklch is chosen, hue interpolates along the shorter arc by default. This is why Tailwind v4 compiles opacity modifiers such as bg-blue-500/50 into color-mix(in oklab, var(--color-blue-500) 50%, transparent) rather than splitting the colour into RGB channels.
There is one subtle trap with polar spaces. Interpolating in oklch keeps chroma high in the middle of a gradient, which can look vivid, but the hue travels around the colour wheel. A gradient from red to blue in oklch passes through purple and pink. The same gradient in oklab cuts straight across the colour space and passes through a less saturated midpoint. Neither is wrong, and choosing between them is a design decision, not a technical default.
Relative colour syntax, the feature that makes OKLCH tokens practical
The deepest change OKLCH brings to stylesheets comes from a CSS Color Level 5 feature, relative colour syntax. It lets one colour be derived from another by modifying its channels directly in CSS.
css
:root {
--brand: oklch(0.6 0.18 260);
}
.button {
background-color: var(--brand);
}
.button:hover {
background-color: oklch(from var(--brand) calc(l - 0.08) c h);
}
.button:disabled {
background-color: oklch(from var(--brand) l calc(c * 0.3) h);
}
.badge-complementary {
background-color: oklch(from var(--brand) l c calc(h + 180));
}The hover state is the brand colour with lightness lowered by 0.08. The disabled state keeps lightness and hue but cuts chroma to 30 percent, so the button looks greyed out without changing its brightness. The complementary badge rotates the hue by 180 degrees.
The same operations in HSL produce uneven results across hues, as shown above. In OKLCH, “lower lightness by 0.08” means approximately the same visual change for every brand colour. A component library can ship one set of derivation rules and have them work for any theme.
Browser support arrived later than for oklch() itself. Firefox shipped relative colour syntax in version 128 in July 2024, the last of the major engines, as part of the Interop 2024 effort. Two compatibility details matter for older versions:
Safari’s first implementation followed an earlier draft. According to MDN, hue calculations in those versions needed explicit units, such as
calc(h + 180deg), where current implementations expect a plain number. Teams supporting older Safari versions should test.Feature detection needs care with custom properties. An
@supportstest containingvar()always passes. MDN recommends testing with a literal colour instead, for example@supports (color: oklch(from red l c h)).
How OKLCH changes what a “colour audit” measures
Stylesheet analysis tools commonly count unique colours as a signal of design-system drift: the more distinct values, the more likely it is that someone hard-coded a colour instead of using a token. OKLCH and its companion features change how this metric should be read.
The same colour can be written many ways. #3b82f6, rgb(59 130 246) and oklch(0.623 0.188 259.8) describe essentially the same blue. A tool that counts colour strings rather than resolved colour values may count them as three different colours. During a migration, the unique-colour count can rise even as the palette is consolidating.
Derived colours may not appear in source at all. With relative colour syntax and color-mix(), hover, active and disabled states are computed in the browser. A stylesheet built this way may contain only a handful of literal colours while rendering dozens of states. A low unique-colour count is then a good sign, because the system is genuinely derived rather than hand-picked. Tools that report only literal values will understate the number of colours users actually see.
Precision becomes noise. OKLCH values copied from design tools often carry many decimal places. Two tokens that differ only in the fourth decimal place of chroma are visually identical but count as distinct strings. Rounding to two or three decimal places for lightness and chroma, and to whole or one-decimal degrees for hue, keeps tokens readable and audits meaningful.
For teams tracking stylesheet health over time, the useful question after a move to OKLCH isn’t how many colour strings exist. It’s how many independent colour decisions exist, and how many colours are derived from tokens by rule.
A migration path that avoids the common mistakes
Moving an established design system to OKLCH doesn’t require rewriting every colour at once. A staged approach keeps the visual output stable.
html
<table>
<thead>
<tr>
<th>Stage</th>
<th>What changes</th>
<th>Main risk</th>
<th>How to control it</th>
</tr>
</thead>
<tbody>
<tr>
<td>1. Convert existing tokens</td>
<td>Hex values rewritten as equivalent oklch() values</td>
<td>None visually, since the colours are identical</td>
<td>Round values sensibly and verify rendered output in visual regression tests</td>
</tr>
<tr>
<td>2. Derive states with relative colour syntax</td>
<td>Hover, active, disabled and border shades computed from base tokens</td>
<td>Unsupported older browsers; Safari draft-syntax differences</td>
<td>Provide literal fallbacks before derived declarations; feature-detect with literal colours</td>
</tr>
<tr>
<td>3. Rebuild scales on lightness steps</td>
<td>Palette steps defined by fixed OKLCH lightness per shade</td>
<td>Chroma exceeding the gamut for some hues</td>
<td>Cap chroma per hue and lightness, generate sRGB fallbacks with proper gamut mapping</td>
</tr>
<tr>
<td>4. Add wide-gamut enhancements</td>
<td>Higher chroma on P3 screens through the color-gamut media query</td>
<td>Inconsistent appearance between screens; uncertain contrast tooling</td>
<td>Restrict to decorative and brand colours; keep text colour pairs in sRGB</td>
</tr>
<tr>
<td>5. Verify accessibility</td>
<td>Contrast checked on final rendered colour pairs</td>
<td>Assuming equal OKLCH lightness means equal contrast</td>
<td>Automated contrast checks in CI on token pairs, not just on base colours</td>
</tr>
</tbody>
</table>The first stage is the most underrated. Converting existing hex tokens to OKLCH without changing them gives the team a vocabulary in which lightness and chroma are visible. The inconsistencies in an existing palette, such as one “500” shade being far lighter than its neighbours, become obvious in the numbers before anyone changes a colour.
What OKLCH does not fix
The enthusiasm around OKLCH sometimes produces claims the model can’t support, so a few limits are worth stating plainly.
It isn’t a perfect model of vision. OKLab is a well-designed approximation fitted to perceptual data. It is much better than HSL and improves on CIELAB in known problem areas, but perceived colour also depends on surrounding colours, viewing conditions and the size of the coloured area. A colour that looks balanced in a small swatch may look different as a full-page background.
It doesn’t guarantee accessibility. As the calculations above show, equal OKLCH lightness gives similar, not identical, WCAG contrast. Accessibility must still be measured with the formula the guidelines actually use.
Hue is undefined for greys. For neutral greys, black and white, chroma is zero and hue has no meaning. Converters may output arbitrary or zero hue values for them. CSS lets authors write none for missing components, which matters when interpolating between a grey and a saturated colour.
It doesn’t make the gamut bigger. OKLCH can describe colours that a screen can’t show, but it can’t make the screen show them. The display hardware sets the limit, and current browsers have mostly clipped what falls outside it.
It is less compact. oklch(0.623 0.188 259.8) is longer than #3b82f6. In a stylesheet built around tokens the difference is negligible, but it is a real cost in inline styles and generated utility classes.
The real shift
The move from hex and HSL to OKLCH is less about a new syntax and more about where colour decisions live. With hex values, every colour is an isolated decision, and consistency depends on discipline. With HSL, colours became adjustable, but the adjustments meant different things for different hues. With OKLCH, relative colour syntax and OKLab interpolation, a design system can define a few base colours and a set of rules, and have those rules behave predictably across hues and themes.
That is why the change has spread from colour-science circles into mainstream tooling within a few years of browser support landing. It also explains why the remaining gaps matter: gamut mapping that isn’t yet implemented as specified, contrast that still has to be verified, and audit tools that need to distinguish a colour string from a colour decision. Teams that understand these limits get the benefits of OKLCH without inheriting its surprises.