Skip to content
WebTricks

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:

ComponentRangeMeaningExamples
L lightness0 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 chroma0 upward, in practice 0 to about 0.37How 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 hue0 to 360 degreesPosition 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

SpaceTypePerceptually uniformGradient midpoint blue → yellowBest for
srgbCube, gamma-encodednogrey, and too darklegacy default, matching old designs exactly
srgb-linearCube, linear lightnogrey, correct brightnessphysically correct light mixing, e.g. blending a glow
hslPolar over sRGBnocolorful, but lightness jumps around the wheelquick hue rotation when you do not care about even lightness
labCartesian, CIEmostlyvivid, straight patholder perceptual option; slight hue shift in blues
oklabCartesian, perceptualyesvivid, straight path, no hue sweeptints and shades of one color, color-mix() with white/black
oklchPolar version of oklabyesvivid, sweeps huemulti-hue gradients, palettes, anything where hue matters
display-p3Cube, wide gamutnogrey (same issue as sRGB), but wider gamut endpointschoosing 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

FeatureChrome / EdgeSafariFirefox
oklch() / oklab() colors11115.4113
in oklch etc. in gradients11116.2113
color-mix()11116.2113
longer hue / shorter hue11116.2113
color(display-p3 …)11110.1113
Relative color syntax oklch(from var(--c) l c h)11916.4128

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