A blue palette that does not turn purple
Blue is the colour most likely to turn into something else. A cobalt button with a blue glow looks right in the design file, then on the page the glow has gone lilac, the gradient ends in violet, and a stack of translucent layers sits somewhere near purple. The causes are specific and each has a fix. What follows is a method for building a blue palette in OKLCH and checking that it holds. Every number in it was computed with the sRGB to OKLab conversion code published in CSS Color Module Level 4, so you can reproduce them.
Step 1: stop using HSL for hue decisions
HSL hue is a position on a colour wheel built from RGB values. It does not track what people see, and the same HSL hue lands at a different OKLCH hue depending on its saturation and lightness. MDN's oklch() page makes the same point: the angles for a given hue differ between the spaces behind hsl() and oklch(). Here are HSL hues at 70 percent saturation and 60 percent lightness, converted to hex and then to the OKLCH hue the colour actually has:
| HSL hue (70%, 60%) | Hex | OKLCH hue |
|---|---|---|
| 200 | #52b1e0 | 233 |
| 210 | #5299e0 | 250 |
| 220 | #5281e0 | 263 |
| 235 | #525ee0 | 274 |
| 240 | #5252e0 | 276 |
| 250 | #6952e0 | 285 |
| 260 | #8152e0 | 294 |
Now the same hues at full saturation and 50 percent lightness, the fully saturated sRGB colours:
| HSL hue (100%, 50%) | Hex | OKLCH hue |
|---|---|---|
| 200 | #00aaff | 242 |
| 210 | #0080ff | 256 |
| 220 | #0055ff | 263 |
| 230 | #002aff | 264 |
| 240 | #0000ff | 264 |
| 250 | #2b00ff | 269 |
| 260 | #5500ff | 280 |
Read the two tables together. At full saturation, HSL 220 to 240 all collapse into a narrow band around 263 to 264 in OKLCH, while at the softer setting the same HSL hues spread from 263 to 276. A palette described as "HSL 230 to 240" can therefore sit at 264 or at 276 depending on two other numbers, and 276 is already past the point where many eyes start to call a colour purple. OKLCH is built so that lightness and chroma changes leave the hue angle roughly where it was, which is why you can pick a band once and keep it. It is not perfect, and the band edges below are judgements, not standards.
These are the working bands for the rest of the page:
- About 195 to 235: cyan to sky. Bright and cool. Highlights, sparks and rims live here.
- About 235 to 268: azure to royal and cobalt. The main brand range.
- Above about 275: increasingly violet. By 280 the colour is plainly purple-leaning. Avoid this range unless you want it.
Step 2: understand why glow drifts violet
Three mechanisms cause nearly all unwanted purple.
1. The starting blue is already near the edge. Pure sRGB blue (#0000ff) sits at OKLCH hue 264, with a chroma of about 0.31. Moving a saturated blue toward red does not take much: adding a red channel of 43 gives #2b00ff at 269, and a red channel of 85 gives #5500ff at 280.
2. Stacked additive layers clip toward the stronger secondary channel. Additive blending, the usual way to draw glows and particles, sums channel values until one reaches its maximum. A saturated blue has its blue channel near the maximum already, so extra layers mostly add to red and green. Whichever of those two is larger decides which way the colour travels on its way to white. Summing 8-bit channel values for two fictional blues:
| Layers | Red-leaning blue rgb(70, 60, 255) | Green-leaning blue rgb(40, 90, 255) |
|---|---|---|
| 1 | #463cff, hue 274 | #285aff, hue 265 |
| 2 | #8c78ff, hue 287 | #50b4ff, hue 244 |
| 3 | #d2b4ff, hue 302 (lilac) | #78ffff, hue 195 (ice cyan) |
The two starting colours differ by a handful of channel values, yet one stack ends lavender and the other ends cyan. A rule of thumb follows: keep green at or above red in every blue you use for glows, fills and particles, so overlaps head toward cyan and white instead of toward magenta. The sums above are on display-encoded values, which is what happens when a framebuffer holds encoded colours and blends additively. Repeating the sum in linear light gives different numbers but the same split: the red-leaning blue goes from hue 274 to 283 over three layers while the green-leaning one goes from 265 to 257.
Ordinary alpha compositing behaves differently. A glow of oklch(0.62 0.20 262) (that is #3d7efc) at 35 percent opacity, laid three times over the ground colour #020917 and blended in display-encoded sRGB, produces #173267, #244d9b and #2d5ebd. Their OKLCH hues are 262.1, 262.1 and 262.0. Normal blending does not move the hue because every result is a mix of two colours that share a hue. The same experiment with a violet-leaning glow, #7e6cf7 at hue 285, ends at 284. So the drift you see in glows comes from additive modes and from the choice of the glow's own hue, not from translucency as such.
3. Gamut clipping. A colour specified with more chroma than sRGB can show has to be brought into range, and the method decides what happens to its hue. Converting oklch(0.75 0.20 255) to sRGB gives channel values of 0.279, 0.682 and 1.162, so blue overshoots. Clipping each channel to 1 gives #47aeff, whose real hue is 245.9 and chroma 0.151: nine degrees off and much less saturated than asked for. Reducing chroma at the same lightness and hue until the colour fits gives #73b1ff, at a chroma of about 0.131, and keeps the hue at 255. CSS Color 4 says that out-of-gamut colours are gamut mapped for display by reducing chroma in OKLCH at constant lightness and hue, and it lets each browser choose among three algorithms. Do not count on that for your whole pipeline. A shader that writes raw channel values, or a canvas pipeline that never sees a CSS colour, clamps without mapping. Pick chroma that is inside sRGB at the lightness you chose and you never find out which behaviour you have.
Step 3: build the ladder
Fix a hue band, then vary lightness and chroma, not hue. These tokens are fictional, all inside the sRGB gamut, and each hex value is the conversion of the OKLCH value beside it:
:root {
/* grounds: low chroma, hue 258 */
--ground: #020917;
--surface: #081425;
--line: #223956;
/* text: nearly neutral, a trace of hue 250 */
--text: #ecf3fa;
--text-dim: #a9b9ca;
/* accents */
--accent: #3d7efc;
--accent-hi: #6aa7f4;
--cyan: #59d6fa;
}
@supports (color: oklch(0.5 0.1 250)) {
:root {
--ground: oklch(0.14 0.035 258);
--surface: oklch(0.19 0.040 258);
--line: oklch(0.34 0.060 255);
--text: oklch(0.96 0.012 250);
--text-dim: oklch(0.78 0.030 250);
--accent: oklch(0.62 0.200 262);
--accent-hi: oklch(0.72 0.130 255);
--cyan: oklch(0.82 0.120 220);
}
}
The hex block comes first and the @supports block overrides it only where oklch() parses. Writing the two declarations one after the other inside a single rule, which is how you would do it for a normal property, does not work for custom properties: a custom property accepts almost any value, so the second line always wins, and a browser that cannot parse oklch() then finds an invalid value where var(--accent) is used and has no hex line to go back to (MDN's var() page describes values that are invalid at computed-value time). MDN marks oklch() as widely available across current browsers, so this is a safety net, but a cheap one.
Measured against --ground, the contrast ratios (WCAG relative-luminance formula) are 17.81:1 for text, 9.94:1 for dim text, 5.30:1 for the accent, 8.01:1 for the lighter accent and 11.79:1 for cyan, so all clear the 4.5:1 threshold for body text discussed in dark interfaces that stay readable. Converting the hex values back to OKLCH recovers the declared lightness and chroma to within a thousandth, and the hue to within about two degrees: --ground comes back at 256.2 against a declared 258. Very dark colours have little chroma, so one step in an 8-bit channel moves their hue by several degrees (nudging #020917 by one step in any channel gives hues between 252.5 and 260.0). Do not worry about that spread on grounds, but never judge hue from a near-black swatch.
Notice what the ladder does. Ground, surface and line share a hue and differ in lightness. The accent gains lightness as it gets brighter and loses chroma, because the sRGB gamut narrows toward white. Holding chroma constant while raising lightness is how a colour leaves the gamut and gets clipped. The accent oklch(0.62 0.20 262) is only just inside: the largest in-gamut chroma at that lightness and hue is about 0.205, and at lightness 0.75 the same hue tops out near 0.128.
Step 4: make glows lean cyan
Take the glow colour from the cool end of the band and let overlaps travel toward cyan:
.glow {
background: radial-gradient(closest-side,
oklch(0.62 0.20 262 / 0.35), transparent);
}
.rim {
box-shadow: 0 0 40px oklch(0.72 0.13 245 / 0.45);
}
In shaders and particle systems, apply the same rule to the base colour you multiply by alpha: choose a vec3 whose green is not below its red. Particle systems in the browser shows where that colour goes in the code. For gradients, interpolating in a perceptual space avoids dull midpoints and surprise hue changes:
background: linear-gradient(in oklch, var(--accent), var(--cyan));
In a polar space such as oklch, hue interpolation takes the shorter way around the wheel by default, and you can name the method explicitly: in oklch shorter hue or in oklch longer hue. If two blues sit on either side of a violet, the longer route passes through it, so keep both ends inside your band, which is the better fix. color-mix(in oklch, ...) follows the same default, according to MDN.
Step 5: optional wide gamut
Many screens can show colours outside sRGB. Treat that as an upgrade you add after the safe palette works:
@supports (color: oklch(0.5 0.1 250)) {
@media (color-gamut: p3) {
:root { --accent: oklch(0.62 0.23 262); }
}
}
MDN describes color-gamut: p3 as the browser and output device supporting approximately the Display P3 gamut or more. It is an approximation: the device still decides how colours are rendered. Never let meaning, contrast or brand recognition depend on the wider value.
Step 6: test on real screens
A palette is not done until it has been looked at, because a design file is one display, one brightness and one white point.
- Compute the hue of every token and of the layered results. Sample pixels from a screenshot of the finished page, convert them with the same code, and confirm the rendered hues stay in your band. The glow experiment above is the pattern: three layers should land within a degree or two of the token. A drift of more than a few degrees between token and screenshot means clipping or additive blending is changing it.
- Check three screens: a laptop panel, an external monitor and a phone. Panels differ in gamut and in how they render dark saturated blues.
- Switch the display's colour-temperature features on and off. Night modes warm the whole image and reduce the blue channel, which can pull a cobalt toward grey or lilac.
- Test at low and high brightness. Highly saturated blues turn dark and muddy at low brightness, another reason for moderate chroma.
- Test at the stack depth that really occurs: the busiest part of the page, with every glow, particle and overlay on at once.
- Desaturate a screenshot to check that hierarchy survives without hue. Then repeat in a simulated colour-vision-deficiency mode; blue and purple sit close together for some viewers, so do not carry meaning in hue alone.
Checklist
- Define hue bands in OKLCH, not HSL: grounds and accents 235 to 268, highlights 195 to 235, nothing above about 275.
- Hold hue constant across a ladder and change lightness, reducing chroma as lightness rises.
- Keep green at or above red in colours that will be added together.
- Stay within sRGB first, wrap
oklch()custom properties in@supports, and add Display-P3 values only as an enhancement. - Measure rendered pixels, on several screens, at the deepest overlap on the page.
Questions and answers
What hue range is blue in OKLCH?
Roughly 235 to 268 degrees covers azure to royal blue, with sky and cyan blues below that; pure sRGB blue (#0000ff) sits at 264. Above about 275 blues lean violet, and by about 280 most viewers read them as violet, so a palette that must stay blue keeps its hues below 275. Treat the band as a working rule and check it by eye.
Why does my blue look purple on one screen and blue on another?
Displays differ in gamut, white point and brightness, and colour-temperature settings shift everything. Saturated blues near the edge of the sRGB gamut are the most exposed to those differences. Choose moderate chroma and test on more than one device.
Is HSL hue the same as OKLCH hue?
No. HSL hue is a rotation of RGB values, and the same HSL hue lands at different OKLCH hues depending on saturation and lightness. HSL 235 at 70 percent saturation and 60 percent lightness computes to OKLCH 274, while HSL 240 at full saturation computes to 264.
Should I use Display-P3 colours?
Optionally, as an enhancement. Define a safe sRGB value first and upgrade it inside a color-gamut: p3 query. Never depend on the wider value for meaning or contrast.
Sources
- MDN: oklch() developer.mozilla.org
- W3C: CSS Color Module Level 4 w3.org
- MDN: color-gamut media feature developer.mozilla.org
- MDN: color-mix() developer.mozilla.org
- MDN: var() and invalid values developer.mozilla.org