Why CSS gradients look muddy in the middle — and how OKLCH gradients fix it
The grey dead-zone in sRGB gradients explained, the in oklch / in oklab color interpolation syntax for linear-gradient and color-mix(), OKLCH vs HSL, a color-space comparison table, browser support and @supports fallbacks.
Ever made a gradient from blue to yellow and watched it turn an ugly grey in
the middle? That is not your eyes — it is the color space the browser
interpolates through. By default, CSS blends gradient stops in sRGB, and the
straight line between two vivid, opposite hues in sRGB runs through the
desaturated center of the color cube. Since CSS Color 4 you can pick a better
space with one keyword: linear-gradient(in oklch, blue, yellow).
This article covers why the muddy middle happens, the in oklch / in oklab
syntax for gradients and color-mix(), how OKLCH compares to HSL and the
other spaces, what the three OKLCH numbers mean, browser support, and the
fallback strategy.
Why sRGB interpolation goes grey
sRGB stores a color as three channels, red, green and blue, each 0–255. A gradient between two stops is a straight line through that cube, channel by channel. Take pure blue and pure yellow:
blue = rgb(0, 0, 255)
yellow = rgb(255, 255, 0)
mid = rgb(128, 128, 128) -> grey
Every channel is averaged independently. Blue has only its B channel on, yellow has R and G on, so the midpoint has all three at half — which is, by definition, a neutral grey. The same thing happens, less dramatically, with any two hues that are far apart on the wheel: magenta to green, red to cyan, even orange to blue. The gradient loses chroma (saturation) in the middle and you get a dull band exactly where you wanted the most color.
There is a second, subtler problem. sRGB values are gamma-encoded: 128
is not half the light of 255, it is about 22%. So an sRGB midpoint is also
darker than the perceptual halfway point, which is why a white-to-black
gradient in sRGB looks like it darkens too fast near the top. Interpolating
in srgb-linear fixes the darkness but not the grey, because the path still
runs through the center of the cube.
The fix is to interpolate in a space where the straight line between two colors stays colorful. In a polar space such as OKLCH, lightness, chroma and hue are separate axes: blending blue and yellow keeps the chroma high and sweeps the hue from 264° to 110°, passing through saturated cyan and green instead of grey.
The in oklch syntax for gradients
Every gradient function accepts a color interpolation method after the direction:
/* default — sRGB, muddy middle */
background: linear-gradient(90deg, blue, yellow);
/* OKLCH — vivid, even */
background: linear-gradient(90deg in oklch, blue, yellow);
/* OKLAB — vivid, straight path through a perceptual space (no hue sweep) */
background: linear-gradient(90deg in oklab, blue, yellow);
/* works for all gradient types */
background: radial-gradient(circle in oklch, #ff3ea5, #00b8d4);
background: conic-gradient(from 0deg in oklch, red, yellow, lime, cyan, blue, magenta, red);
The keyword goes right after the angle or shape and before the first stop.
With no angle you can write linear-gradient(in oklch, blue, yellow) and it
defaults to top-to-bottom. The gradient generator
has an OKLCH interpolation toggle that inserts exactly this keyword;
flip it on a two-color gradient and watch the midpoint come alive.
longer hue, shorter hue, increasing hue, decreasing hue
For polar spaces (oklch, lch, hsl, hwb) there are two ways around
the hue wheel. By default the browser takes the shorter arc. You can ask
for the other direction:
/* shorter arc (default): blue -> magenta -> red, ~115° */
background: linear-gradient(in oklch, blue, red);
/* longer arc: blue -> cyan -> green -> yellow -> red, ~245° */
background: linear-gradient(in oklch longer hue, blue, red);
longer hue is how you get a rainbow out of two stops. increasing hue and
decreasing hue force a direction regardless of which is shorter, useful for
conic hue wheels where you want a predictable sweep.
The same syntax in color-mix()
color-mix() uses the identical interpolation keyword, so one mental model
covers both:
/* 50/50 in OKLCH */
color: color-mix(in oklch, blue, yellow);
/* weighted: 30% blue, 70% yellow */
color: color-mix(in oklch, blue 30%, yellow);
/* a tinted version of a token — the classic use */
background: color-mix(in oklab, var(--brand) 15%, white);
/* longer arc also works here */
color: color-mix(in oklch longer hue, blue, red);
The color mixer blends two colors at any ratio in OKLCH, RGB or HSL so you can see the three results side by side and copy the one you want.
OKLCH syntax explained
oklch(L C H / alpha) has three components with very different ranges:
| Component | Range | Meaning | Examples |
|---|---|---|---|
| L lightness | 0 to 1 (or 0% to 100%) | Perceived lightness. 0 is black, 1 is white. Perceptually even: 0.5 looks halfway. | oklch(0.2 …) is a dark color, oklch(0.9 …) a pastel |
| C chroma | 0 upward, in practice 0 to about 0.37 | How colorful. 0 is grey. The maximum depends on hue and lightness. | 0.02 is nearly neutral, 0.15 is a solid UI color, 0.3 is neon |
| H hue | 0 to 360 degrees | Position on the wheel. Roughly 30° red, 90° yellow, 140° green, 200° cyan, 260° blue, 320° magenta. | oklch(0.7 0.15 260) is a medium blue |
Concrete values:
--blue-600: oklch(0.55 0.2 260);
--blue-100: oklch(0.93 0.04 260); /* same hue, lighter, less chroma */
--grey-500: oklch(0.6 0 0); /* chroma 0 = neutral, hue irrelevant */
--red-500: oklch(0.63 0.24 27);
--accent-translucent: oklch(0.7 0.15 260 / 0.4);
The big practical win over HSL: L is honest. In HSL, hsl(60 100% 50%)
(yellow) and hsl(240 100% 50%) (blue) have the same “50% lightness” but
yellow is obviously far brighter. In OKLCH those are oklch(0.97 0.21 110)
and oklch(0.45 0.31 264). So a palette built by holding L constant and
changing H actually has even lightness — which is exactly what the
palette generator does when it builds
complementary and analogous harmonies, and what the
tints and shades generator relies on to step
lightness evenly. Paste any hex into the
color converter to see its oklch() form.
Color space comparison
| Space | Type | Perceptually uniform | Gradient midpoint blue → yellow | Best for |
|---|---|---|---|---|
srgb | Cube, gamma-encoded | no | grey, and too dark | legacy default, matching old designs exactly |
srgb-linear | Cube, linear light | no | grey, correct brightness | physically correct light mixing, e.g. blending a glow |
hsl | Polar over sRGB | no | colorful, but lightness jumps around the wheel | quick hue rotation when you do not care about even lightness |
lab | Cartesian, CIE | mostly | vivid, straight path | older perceptual option; slight hue shift in blues |
oklab | Cartesian, perceptual | yes | vivid, straight path, no hue sweep | tints and shades of one color, color-mix() with white/black |
oklch | Polar version of oklab | yes | vivid, sweeps hue | multi-hue gradients, palettes, anything where hue matters |
display-p3 | Cube, wide gamut | no | grey (same issue as sRGB), but wider gamut endpoints | choosing vivid endpoints, not for fixing interpolation |
Rule of thumb: oklab for two colors that are close or for mixing with
white, black or grey; oklch when the hues differ and you want a colorful
path. OKLCH’s hue sweep can introduce an intermediate hue you did not ask
for (blue to red passes through magenta in shorter hue), and a stop with
chroma 0 (pure grey, white, black) has no defined hue — browsers then carry
the other stop’s hue, which is usually what you want, but check it. In those
cases oklab is the safer choice.
Browser support
| Feature | Chrome / Edge | Safari | Firefox |
|---|---|---|---|
oklch() / oklab() colors | 111 | 15.4 | 113 |
in oklch etc. in gradients | 111 | 16.2 | 113 |
color-mix() | 111 | 16.2 | 113 |
longer hue / shorter hue | 111 | 16.2 | 113 |
color(display-p3 …) | 111 | 10.1 | 113 |
Relative color syntax oklch(from var(--c) l c h) | 119 | 16.4 | 128 |
In other words, everything in this article has been in all three engines since spring 2023, and global support is well above 90%. What is left are old Android WebViews, Safari 15 on unmaintained iPhones, and Firefox ESR releases before 115.
Fallback strategy with @supports
Because gradients are a single property, the pattern is: declare the sRGB
version first, then override for browsers that understand interpolation
keywords. An unsupported value is ignored, so even without @supports the
cascade falls back correctly — but @supports keeps the two readable and
lets you change more than one property:
.hero {
background: linear-gradient(135deg, #2563eb, #facc15);
}
@supports (background: linear-gradient(in oklch, red, blue)) {
.hero {
background: linear-gradient(135deg in oklch, #2563eb, #facc15);
}
}
For color-mix() the same idea applies; precompute the mixed color for the
fallback:
.tint {
background: #dbeafe; /* precomputed */
}
@supports (color: color-mix(in oklch, red, blue)) {
.tint {
background: color-mix(in oklch, var(--brand) 15%, white);
}
}
If you do not want to maintain a fallback, a middle path is to add an
explicit middle stop in sRGB: linear-gradient(blue, #c050c0, yellow) picks
a colorful midpoint by hand and avoids the grey everywhere.
Gamut mapping caveats
OKLCH can describe colors that no screen can show. oklch(0.7 0.3 150) is a
green more saturated than sRGB (and most P3 displays) can reproduce. When
that happens the browser gamut-maps the color: it reduces chroma until
the color fits, keeping lightness and hue. Consequences:
- A gradient between two in-gamut colors can have an out-of-gamut middle (the path bulges outward in chroma). The browser clips it per pixel, which can leave a slightly flat, “plateaued” stretch of maximum saturation in the center. If you see it, lower the chroma of the endpoints or add a middle stop.
- Different browsers mapped out-of-gamut colors differently in early implementations (chroma reduction vs per-channel clipping). Current versions follow the spec’s chroma-reduction algorithm, but test on a wide-gamut display and an sRGB one.
- The chroma ceiling depends on lightness and hue: yellows reach C ≈ 0.2 at L 0.95, while blues reach C ≈ 0.31 only at L 0.45. That is why the palette generator keeps chroma modest when it builds a scale — a scale with constant high chroma would clip at both ends.
FAQ
What is the difference between OKLCH and OKLAB?
They are the same color space in two coordinate systems. OKLAB is Cartesian (L, a, b); OKLCH is polar (L, chroma, hue) — chroma is the distance from the grey axis and hue the angle. Interpolating in OKLAB draws a straight line that may dip in chroma; in OKLCH it rotates the hue while keeping chroma, so gradients stay colorful.
Is OKLCH better than HSL?
For anything that depends on lightness being comparable across hues, yes. HSL’s lightness is a formula over RGB channels and does not match perception (yellow and blue at 50% look nothing alike). OKLCH’s L is perceptual, so constant-L palettes look even and contrast calculations are more predictable. HSL remains fine for a quick hue rotation of one color.
Does in oklch work in color-mix() too?
Yes. color-mix(in oklch, a, b) uses the same interpolation keywords and
hue arc modifiers as gradients, with the same browser support (Chrome 111,
Safari 16.2, Firefox 113).
Why does my OKLCH gradient pass through pink?
Because the shorter hue arc between your two stops crosses magenta. Add
longer hue to go the other way around the wheel, or switch to
in oklab, which takes a straight line without a hue sweep.
Do I still need the sRGB fallback in 2026?
For public sites, keep it — it costs one extra line and protects old WebViews and Firefox ESR. For internal tools targeting evergreen browsers you can drop it.
Try it
- Gradient generator — toggle OKLCH interpolation and compare the midpoints.
- Color mixer — blend two colors in OKLCH, RGB or HSL.
- Color converter — see any color as
oklch(). - Palette generator — OKLCH scales and harmonies.
- Tints and shades — even lightness steps of one color.