iKit
Technical · 9 min read ·

CSS Color Module Level 4: Every New Function (2026)

CSS Color Module Level 4 added oklch, oklab, lab, lch, hwb and color(). Here is what each function does, the exact value ranges, and 2026 browser support.

CSS Color Module Level 4: Every New Function (2026)

CSS Color Module Level 4: Every New Function

For twenty-five years CSS could only describe colors inside sRGB. CSS Color Module Level 4 changed that: it added lab(), lch(), oklab(), oklch(), hwb() and color(), plus the rules browsers use to interpolate and gamut-map between them. As of November 2025 the whole set is Baseline widely available. This guide covers each function, its exact value ranges, and what belongs to Level 5 instead.

TL;DR

  • Level 4 adds lab(), lch(), oklab(), oklch(), hwb() and color() to CSS.
  • color-mix(), relative color syntax and light-dark() are Level 5, not Level 4.
  • All six Level 4 functions hit Baseline widely available on 9 November 2025.
  • In oklch(), 100% chroma means 0.4 — not 1. Common source of bugs.
  • Prefer oklch() over lch(): LCH shifts blues toward purple.

What is CSS Color Module Level 4?

CSS Color Module Level 4 is the W3C spec that rebuilt CSS color from the ground up. It formalised the modern space-separated syntax (rgb(255 0 0 / 0.5) instead of rgba(255, 0, 0, 0.5)), added device-independent color spaces, and — crucially — specified what a browser must do when you write a color it cannot display.

Which functions are actually in Level 4

Six new color notations, plus the modern syntax for the legacy ones:

  • lab() and lch() — CIE Lab and its polar form, D50 whitepoint
  • oklab() and oklch() — the improved Lab model, D65 whitepoint
  • hwb() — hue, whiteness, blackness; sRGB only
  • color() — an escape hatch into named color spaces like Display P3
  • none as a component value, creating "missing components" for interpolation

What Level 5 adds on top

This is where most articles get it wrong. color-mix() is not in Level 4. It lives in CSS Color Module Level 5, a delta spec that also defines relative color syntax, light-dark(), device-cmyk() and custom color profiles. A newer Level 6 Editor's Draft now hosts contrast-color() and color-layers(), and is explicitly marked "not ready for implementation."

Where the spec stands in 2026

Level 4 has been a Candidate Recommendation since 5 July 2022, with the current Candidate Recommendation Draft dated 28 July 2026 — the CSS Working Group has published more than a dozen drafts in 2026 alone. Level 5 is still a Working Draft. Neither is a finished Recommendation, and that no longer matters much: interoperability, not REC status, is what decides whether you can ship.

How to use oklch() and oklab() in CSS

oklch() describes a color as lightness, chroma and hue. oklab() describes the same color as lightness plus two Cartesian axes. Both sit on Björn Ottosson's Oklab model, published in December 2020 and cited normatively by the CSS spec.

:root {
  /* L = 0–1, C ≈ 0–0.4, H = 0–360deg */
  --brand:      oklch(0.62 0.19 264);
  --brand-soft: oklch(0.62 0.19 264 / 0.15);

  /* identical color, Cartesian form */
  --brand-alt:  oklab(0.62 -0.02 -0.19);
}

The channel ranges that trip everyone up

Percentages do not behave uniformly across these functions. MDN calls this out directly on the oklch() page: 100% equals 0.4 for chroma, but 1 for lightness. The same asymmetry runs through the whole family.

Function Channel 100% equals
oklch() L / C 1 / 0.4
oklab() L / a, b 1 / 0.4
lch() L / C 100 / 150
lab() L / a, b 100 / 125

So oklch(70% 100% 250) is oklch(0.7 0.4 250) — a wildly saturated color, not a mildly saturated one. Pick one form per codebase and stay with it. The iKit Color Picker shows the number form alongside HEX and RGB, which is the fastest way to sanity-check a value you inherited.

Why does lch() blue look purple

Hold lightness and hue fixed in lch(), raise chroma, and blues drift toward purple. Chrome's color-spaces guide pins the affected range: hue values between 270deg and 330deg shift chroma and lightness in ways the author did not ask for. The CSS Color 4 spec's own figure captions describe a "noticeable purpling" in the LCH slice around primary blue, and a constant visual hue in the OkLCh slice.

That single difference is why design systems generate tints and shades in OkLCh: the operation you perform most often — vary chroma, keep hue — is the operation LCH handles worst.

When to reach for oklab instead

Use oklch() for authoring, oklab() for math. Anything that averages or blends colors is cleaner in the Cartesian form because there is no hue angle to wrap around 360°. That is also why Oklab is the default interpolation space for CSS gradients and mixes.

How to use the color() function for Display P3

color() takes a color space name followed by three coordinates, each 01 across the space's bounds:

.vivid {
  /* sRGB fallback first — dropped if unparsed */
  background: #0a84ff;
  background: color(display-p3 0.04 0.52 1);
}

Every predefined color space in color()

Ten are defined: srgb, srgb-linear, display-p3, display-p3-linear, a98-rgb, prophoto-rgb, rec2020, xyz, xyz-d50 and xyz-d65. Plain xyz is a synonym for xyz-d65. The RGB-style spaces take r g b channel keywords in relative syntax; the XYZ ones take x y z, and mixing the two sets is a parse error.

When to use display-p3 instead of sRGB

Use it when the saturation you want does not exist in sRGB — brand accents, data-viz categoricals, product photography overlays. Chrome's high-definition color guide frames it as roughly 50% more colors available on capable displays. Two caveats worth knowing:

  • On an sRGB screen the browser gamut-maps the color down, so you get something sensible, not a broken render.
  • @supports tells you the syntax parses. @media (color-gamut: p3) tells you the display can actually show it. They answer different questions.

How does color-mix() work in CSS

color-mix() blends two colors in a named interpolation space. It is the highest-leverage function in the whole family because it turns one brand token into an entire scale.

:root { --brand: oklch(0.62 0.19 264); }

.card        { border: 1px solid
               color-mix(in oklab, var(--brand) 30%, white); }
.card:hover  { background:
               color-mix(in oklab, var(--brand) 12%, white); }
.card:active { background:
               color-mix(in oklab, var(--brand) 20%, black); }

Why percentages that don't add to 100 change alpha

This is the behaviour that surprises people. The spec normalises percentages, then uses whatever is missing to scale alpha. Given color-mix(in srgb, red 20%, green 60%), the sum is 80%, so the ratios rescale to 25%/75% and the result's alpha is multiplied by 0.8. Go over 100% instead and the percentages simply scale down with no alpha effect — purple 80%, plum 80% is just a 50/50 mix.

Omit one percentage and it becomes 100% minus the other. Omit both and you get 50/50. Negative percentages are invalid.

Interpolation happens on premultiplied alpha, which matters more than it sounds: the spec's own worked example shows the correct and naive results differing by ΔE2000 = 30.7 — a difference nobody would call subtle.

Hue interpolation: shorter, longer, increasing, decreasing

In polar spaces (hsl, hwb, lch, oklch) you can append a hue interpolation method. The default is shorter, which takes the short way around the wheel; longer takes the long way; increasing and decreasing always go clockwise or counterclockwise regardless of distance.

/* short arc — muddy through grey */
background: linear-gradient(
  in oklch shorter hue, yellow, blue);

/* long arc — sweeps the full rainbow */
background: linear-gradient(
  in oklch longer hue, yellow, blue);

The practical reason to use increasing/decreasing is animation: they do not flip sides when the hue difference crosses 180°, so a rotating hue stays smooth. The iKit Gradient Generator previews all four against the same stops if you would rather see it than reason about it.

Relative color syntax and the rest of Level 5

Relative color syntax lets a function read channels off an origin color:

:root { --base: oklch(52.6% 0.115 44.6deg); }

.summary { background:
  oklch(from var(--base) l c calc(h + 90)); }
/* → oklch(0.526 0.115 134.6) */

Why channel keywords are always unitless numbers

l, c, h and friends resolve to plain <number> values. A percentage origin resolves to its number form; an angle resolves to a number of degrees in [0, 360] no matter whether it was authored in deg, rad or turn. Safari 16.4 shipped an older revision where units survived, which is why you still see calc(h + 180deg) guarded behind @supports in older codebases.

Relative syntax also only works with modern color syntax — the origin can be legacy rgb(255, 0, 0), but the outer function cannot use commas.

light-dark() and contrast-color()

light-dark() picks between two values based on the used color-scheme, so it only does anything if you set color-scheme: light dark on the root. It has been Baseline newly available since 13 May 2024. contrast-color() — which auto-selects a readable foreground — is newer still: Safari 26 shipped it in September 2025, Chrome and Edge 147 in April 2026. Its keyword syntax is still an open CSSWG issue, so treat any code sample you find as provisional and check contrast yourself against WCAG thresholds.

Browser support and fallbacks in 2026

Baseline status at a glance

Feature Baseline Since
oklch() / oklab() Widely available 2025-11-09
lab() / lch() Widely available 2025-11-09
color() Widely available 2025-11-09
color-mix() Widely available 2025-11-09
hwb() Widely available 2024-10-28
Relative color syntax Newly available 2024-09-16
light-dark() Newly available 2024-05-13

The first four crossed into "widely available" on the same day — nine months ago at the time of writing. That is the fact that changes how you should write CSS today: oklch() and color-mix() no longer need a story, they need a value.

@supports patterns that actually work

For the widely-available functions, the cascade is enough. Declare sRGB first; an unparseable declaration is dropped and the earlier one wins:

.btn {
  background: #4499bb;
  background: oklch(0.65 0.1 240);
}

For relative color syntax — the one feature here still short of widely available — a real feature query earns its keep:

@supports (color: hsl(from white h s l)) {
  .btn { background:
    oklch(from var(--brand) calc(l * 0.9) c h); }
}

If you are generating a full scale rather than one-off adjustments, doing it once in a tool and exporting fixed tokens sidesteps the support question entirely; the iKit Color Palette Generator builds OkLCh ramps in the browser and hands back plain values.

References

Related on iKit

Related posts