Mert Özen Further With Every Line
Frontend CSS3: Flexbox, Grid and Modern CSS — #11

Modern Color and CSS Math: oklch(), color-mix(), Relative Color, contrast-color() and Math Functions

Mert Özen Nov 14, 2026 13 min 2 views
Modern Color and CSS Math: oklch(), color-mix(), Relative Color, contrast-color() and Math Functions

oklch() measures lightness the way the human eye perceives it, so a yellow and a blue with the same L look equally bright, while in HSL the contrast of white text ranges from 1.07 to 8.59. We derive shades from Bookstore's category colour with relative colour, color-mix() and alpha(), and measure the limit of contrast-color(), round/mod/rem, progress() and typed arithmetic, and the not-yet-Baseline if(), @function and random().

Version note: This post follows the CSS Color Level 4/5 and CSS Values Level 4/5 specifications and the state of Baseline in November 2026; the examples were run in Chrome 158 and WebKit 27.2 via Playwright 1.65, and Firefox support is taken from web-features 3.46 data and web.dev's monthly "New to the web platform" posts.

Guess First

A designer picked the category colours of Bookstore, the online bookstore used as the shared example across this site's series, with HSL: all of them with the same saturation and the same lightness, hsl(H 100% 50%), only the hue changes. The idea: since lightness is 50% in all of them, white text written on top will be equally readable on each. On the lab page I put two of them side by side:

/* 1) the same "lightness" in hsl and in oklch */
#hsl-yellow { background-color: hsl(60 100% 50%); }
#hsl-blue { background-color: hsl(240 100% 50%); }
#oklch-yellow { background-color: oklch(0.7 0.15 100); }
#oklch-blue { background-color: oklch(0.7 0.15 265); }

Look at the first two: yellow hsl(60 100% 50%) and blue hsl(240 100% 50%). Is the contrast ratio of white text on these two backgrounds the same, close, or very different?

In the previous post we added a dark theme to Bookstore and brought the category colour from the CMS into the page as a custom property. In this post we move on to the colours themselves: oklch(), which measures perceived lightness correctly; relative colour and color-mix(), which derive a palette from a single colour; alpha(), which changes only the transparency; contrast-color(), which picks the text colour on its own, and its limits. The second half covers CSS's math functions and if(), @function and random(), which are not Baseline yet.

From Hex to oklch: Perceived Lightness

node scripts/11-color.mjs
--- chromium
hsl-yellow    #ffff00  luminance 0.928  contrast with white 1.07
hsl-blue      #0000ff  luminance 0.072  contrast with white 8.59
oklch-yellow  #b59f00  luminance 0.346  contrast with white 2.65
oklch-blue    #709afb  luminance 0.335  contrast with white 2.73
base          rgb(37, 99, 235)                         = #2563eb
oklch-of-hex  oklch(0.54613 0.215194 262.885)          = #2563eb
darker        oklch(0.44613 0.215194 262.885)          = #0442c8
soft          oklch(0.95 0.0645582 262.885)            = #d8efff
mix-black     oklch(0.464211 0.182915 262.885)         = #1c4ebd
mix-srgb      color(srgb 0.572549 0.694118 0.460784)   = #92b175
mix-oklch     oklch(0.757057 0.213095 186.32)          = #00d8c5
faded         color(srgb 0.145098 0.388235 0.921569 / 0.15)
accent-light  on #b91c1c: contrast-color() = rgb(255, 255, 255); black 3.25, white 6.47
accent-dark   on #f87171: contrast-color() = rgb(0, 0, 0); black 7.59, white 2.77
royal-blue    on #2277d3: contrast-color() = rgb(0, 0, 0); black 4.65, white 4.52
round(up, 0.9rem, 4px) = 16px, mod(-7px, 3px) = 2px, rem(-7px, 3px) + 10px = 9px, abs(-12px) * sign(-0.5) + 20px = 8px
480px shelf: --cols 2, progress(100cqi, 20rem, 40rem) 0.5
if() 200px, @function 42px, random() step 50px one of 100/150/200px

The first two lines answer the guess. Both have an HSL lightness of 50%, but the relative luminance of the yellow is 0.928 and that of the blue 0.072. White text has a contrast of 1.07:1 on the yellow, practically invisible, and 8.59:1 on the blue. HSL's lightness has nothing to do with how the human eye perceives brightness; it is a mathematical transformation of the sRGB channels. The eye sees yellow as much brighter than blue, and HSL does not account for that.

OKLCH is a colour space designed to solve this problem. In oklch(L C H), L is perceived lightness (0–1), C is chroma, that is, colourfulness, and H is the hue angle. The third and fourth lines have a yellow and a blue with the same L (0.7) and the same C (0.15): their luminance values are 0.346 and 0.335, their contrast with white 2.65 and 2.73. The hue changed, the perceived lightness stayed almost the same. When you build a palette in oklch, the "this category looks darker than the others" problem largely goes away, and when you want to change lightness, you touch only L.

To see the oklch equivalent of an existing hex colour, you can ask the browser. Bookstore's Science Fiction blue #2563eb converted with oklch(from #2563eb l c h) comes out as oklch(0.54613 0.215194 262.885) (oklch-of-hex in the output), and converted back to sRGB it is #2563eb again. One warning: oklch can also describe colours more vivid than sRGB can show. If high chroma values fall outside the screen's gamut, the browser maps the colour to the nearest one the screen can show; so choose C while looking at it in a design tool.

A Palette from One Colour: Relative Colour and color-mix()

In the previous post we brought the category colour into the page with --cat-color; the editor picks a single colour. Instead of asking for shades such as a hover background, a border or a light background separately for every category, we can derive them from that one colour:

/* 2) a palette derived from one category colour */
:root { --cat: #2563eb; }
#base { background-color: var(--cat); }
#oklch-of-hex { background-color: oklch(from var(--cat) l c h); }
#darker { background-color: oklch(from var(--cat) calc(l - 0.1) c h); }
#soft { background-color: oklch(from var(--cat) 0.95 calc(c * 0.3) h); }
#mix-black { background-color: color-mix(in oklch, var(--cat), black 15%); }
#mix-srgb { background-color: color-mix(in srgb, var(--cat), yellow); }
#mix-oklch { background-color: color-mix(in oklch, var(--cat), yellow); }
#faded { background-color: alpha(from var(--cat) / 15%); }

The relative colour syntax, written as oklch(from COLOR l c h), takes a colour apart into its channels; it lets you use each channel like a variable and put them back together. #darker lowers the lightness by 0.1 (calc(l - 0.1)) and the result is #0442c8; the hue and chroma stay the same. #soft pins the lightness at 0.95 and cuts the chroma to 30%: #d8efff, a very light background in the category's colour. Because I do these operations in oklch, "0.1 darker" gives the same perceived difference for every category.

color-mix() mixes two colours in a given ratio and a given colour space. color-mix(in oklch, var(--cat), black 15%) moves the colour 15% towards black. The mixing space changes the result directly: mixing the same blue and yellow in sRGB gives a greyish green (#92b175), in oklch a vivid cyan (#00d8c5). In sRGB the channels are averaged along a straight line and the colours in between get dull; in oklch the hue angle turns along the short way and chroma is kept. For hover and active shades oklch gives more predictable results; when you want a "real mixture", such as a gradient between two brand colours, you can pick a different space.

alpha() and Hover Shades in Bookstore

The last colour in the lab is alpha(from var(--cat) / 15%): it changes only the transparency and leaves the colour itself alone. The same can be written with relative colour as oklch(from var(--cat) l c h / 15%); alpha() is just shorter and states the intent more clearly. It became Baseline in September 2026. I used it in two places in Bookstore. The hover background of the category links now comes from the category's own colour:

/* hover tint derived from the category colour: the same colour at 15% opacity */
.cat-link:hover {
    background-color: alpha(from var(--cat-color, var(--color-muted)) / 15%);
}

The add-to-cart button, on the other hand, has not changed on hover at all so far: the vendor kit's .btn-primary:hover rule sits in a lower layer, so the red background we set in the components layer in the first post of this series beat it. I derived the hover shade from the same accent:

.add-to-cart {
    background-color: var(--color-accent);
}

/* in the dark theme the accent is light red: white text would only reach 2.77:1 */
@supports (color: contrast-color(red)) {
    .add-to-cart {
        color: contrast-color(var(--color-accent));
    }
}

/* hover: the same accent, 15% of the way to black, mixed in oklch */
.add-to-cart:hover {
    background-color: color-mix(in oklch, var(--color-accent), black 15%);
}
chromium light add-to-cart hover rgb(255, 255, 255) on oklch(0.429613 0.161935 27.519) | badge rgb(255, 255, 255) on rgb(185, 28, 28) | category hover color(srgb 0.145098 0.388235 0.921569 / 0.15)
chromium dark  add-to-cart hover rgb(0, 0, 0) on oklch(0.604037 0.141252 22.2179) | badge rgb(0, 0, 0) on rgb(248, 113, 113) | category hover color(srgb 0.145098 0.388235 0.921569 / 0.15)

The first rule in this file is the topic of the next section. In the script's last two lines, which measure Bookstore, the hover background is oklch(0.429613 …) in the light theme and oklch(0.604037 …) in the dark theme; both are 15% darker than that theme's accent. I did not write a separate hover colour for the dark theme; when the token changes, the derived colour changes with it.

contrast-color(): Black or White?

In the dark theme the accent is a light red (#f87171). The button's white text gives only 2.77:1 contrast on that background, far below the 4.5:1 WCAG 2 asks for normal text. contrast-color(COLOR) returns the colour, white or black, that gives the higher contrast with the given colour. I tried it on three backgrounds in the lab:

/* 3) contrast-color() picks black or white */
.on { color: contrast-color(var(--bg)); background-color: var(--bg); }
<p class="on" id="accent-light" style="--bg: #b91c1c">Bestseller</p>
<p class="on" id="accent-dark" style="--bg: #f87171">Bestseller</p>
<p class="on" id="royal-blue" style="--bg: #2277d3">Bestseller</p>

In the accent-light line of the output, white was chosen on the light theme's accent #b91c1c (6.47:1); in accent-dark, black on #f87171 (7.59:1). Bookstore's badge and button now make that decision themselves; in the Bookstore output above there is white text in the light theme and black text in the dark theme.

The third line shows the limit. On a mid-tone blue such as #2277d3, black has a contrast of 4.65 and white 4.52; the function chose black, and both values barely clear 4.5:1. MDN gives this very example and notes that black is not readable as small text there. contrast-color() only picks the better of two candidates; it does not guarantee that the chosen colour is good enough. On top of that, the specification leaves the choice of contrast algorithm to the browser. Avoiding mid-tone backgrounds, choosing colours from the light or dark ends and measuring the result is still your job. The details of contrast calculation and the limits of WCAG on this are in the upcoming Accessibility (a11y) series.

There is also a detail in how to write it. contrast-color() became Baseline in April 2026; for older browsers the badge keeps its color: #ffffff. But if I wrote the new rule right below it, I would fall into the trap from the previous post: since contrast-color(var(--color-accent)) contains a var(), it is treated as valid at parse time and drops #ffffff; in a browser that does not know the function it then becomes invalid at the computed-value stage and the text colour falls back to the inherited value. That is why I put the rule inside @supports:

/* black or white, whichever contrasts more with the accent; #ffffff above stays for older browsers */
@supports (color: contrast-color(red)) {
    .badge {
        color: contrast-color(var(--color-accent));
    }
}

CSS Math: round(), mod(), rem(), abs() and sign()

We used calc(), min(), max() and clamp() in earlier posts of this series. In the last two years, rounding and sign functions were added to them:

/* 4) math functions */
#round { inline-size: round(up, 0.9rem, 4px); }
#mod { inline-size: mod(-7px, 3px); }
#rem { inline-size: calc(rem(-7px, 3px) + 10px); }   /* rem() keeps the sign: -1px + 10px */
#abs { inline-size: calc(abs(-12px) * sign(-0.5) + 20px); }
@property --cols { syntax: "<number>"; inherits: false; initial-value: 0; }
.shelf { container-type: inline-size; inline-size: 480px; }
#fit {
    /* typed arithmetic: length / length is a plain number */
    --cols: round(down, calc((100cqi + 1rem) / 15rem));
    /* where 480px sits between 20rem and 40rem, as 0..1 */
    opacity: progress(100cqi, 20rem, 40rem);
}

Look at the math line in the lab output: round(up, 0.9rem, 4px) rounded 14.4px up to the next multiple of 4px, 16px. That is handy for snapping a spacing value that does not sit on a design system's 4px grid, one computed with rem or % for example, onto the grid; there are also nearest, down and to-zero strategies. mod() and rem() differ for negative numbers: mod(-7px, 3px) takes the sign of the divisor and gives 2px, rem(-7px, 3px) takes the sign of the dividend and gives -1px (I measured it as 9px after adding 10px in the lab, because a width cannot be negative). For things like bringing angles into the 0–360 range, mod() is the right choice. abs() and sign() separate a value's magnitude from its direction: abs(-12px) * sign(-0.5) + 20px is 12 × (-1) + 20 = 8px.

progress() and Typed Arithmetic

.shelf in the lab is a 480px wide container, and #fit uses two new features together. progress(100cqi, 20rem, 40rem) gives where 480px sits between 320 and 640 pixels as a number between 0 and 1: 0.5. It lets you write "where am I in the range" explicitly instead of squeezing fluid values into clamp(); you can use the result inside calc() to move between two values. It became Baseline in September 2026.

Typed arithmetic means that calc() can divide values with units by each other: (100cqi + 1rem) / 15rem divides a length by a length, and the result is a unitless number. Before, calc() only allowed dividing by a number. In this example the result is (480 + 16) / 240 = 2.07; with round(down, …) it is 2. So CSS itself works out how many 14rem cards, with 1rem between them, fit onto a 480px shelf. I could read the value in --cols, which I registered as <number> with @property; in an unregistered property it would stay as uncomputed text. Typed arithmetic became Baseline in October 2026 with Firefox 158; Chrome had supported it since 140.

On the Horizon: if(), @function, random()

Three more features could change how CSS is written, but none of them is Baseline in November 2026:

/* 5) not Baseline: only behind @supports */
#if-test { inline-size: 10px; }
@supports (width: if(media(width > 1px): 1px; else: 2px)) {
    #if-test { inline-size: if(media(width >= 48rem): 200px; else: 100px); }
}
@function --double(--x) { result: calc(var(--x) * 2); }
#function-test { inline-size: 10px; inline-size: --double(21px); }
#random-test { inline-size: 10px; }
@supports (width: random(1px, 2px)) {
    #random-test { inline-size: random(100px, 200px, 50px); }
}

if() picks a value inside a declaration based on a condition; according to caniuse only Chrome 137 and later support it, Safari and Firefox do not. @function lets you define your own functions in CSS; web-features data lists only Chrome 139 and later. random() arrived with Safari 26.2 and Chrome 156, and is only in Technology Preview in Firefox. In the last line of the lab output all three seem to work: if() 200px, @function 42px, random() 100, 150 or 200px. Do not misread that result. It is accurate for Chrome 158, but Playwright's WebKit build is not the same as released Safari, and it ships some features that are not in Safari yet; if() and @function working there does not mean they work in Safari.

That is why I wrote all three defensively in the lab. if() and random() are inside @supports. Above the declaration that uses the result of @function I put a fallback: in a browser without support, --double(21px) is an invalid value at parse time, so the declaration is dropped and 10px stays. That is different from var(); if the custom function call is not recognised, the browser rejects it while it is still parsing. In production, use these three only as decoration, in places where the page looks right without them.

In Production

The features in this post have very different ages. oklch(), oklab() and color-mix() have been Baseline since May 2023 and widely available since November 2025. Relative colour (September 2024), round()/mod()/rem() (May 2024) and abs()/sign() (June 2025) are Baseline but not widely available yet. contrast-color() (April 2026), progress() and alpha() (September 2026) and typed arithmetic (October 2026) are new. Wherever you use them, think about what a browser without support shows: in Bookstore, if the hover shade never arrives, the button stays red; without contrast-color() the badge keeps its white text. Nowhere does content disappear.

When you copy colour values from a design file, mind the colour space. Design tools mostly hand out colours as hex; converting them to oklch once and writing the tokens in oklch makes every later derivation (hover, light background, dark theme) consistent. Just remember that values outside the gamut may look different on different screens.

Common Pitfalls

First, taking HSL's lightness for perceived lightness. As we measured, between a yellow and a blue with the same lightness there is a more than eightfold difference in contrast for white text.

Second, choosing the colour space in color-mix() without thinking. in srgb dulls the colours in between; for hover and active shades, in oklch is more consistent.

Third, treating contrast-color() as an accessibility guarantee. It picks the better of two options; on mid-tones both can be insufficient.

Fourth, writing a new function containing var() right below a fixed fallback. In a browser that does not know the function, the fallback has already been dropped; put the new function inside @supports.

A Small Experiment

★☆☆ Change #mix-oklch in the lab to color-mix(in oklch longer hue, var(--cat), yellow). Guess which way the hue angle turns this time and which colour the result will be close to, then check with node scripts/11-color.mjs.

★★☆ Add an underline derived from the category colour to the category links: the colour itself in the light theme, oklch(from var(--cat-color) calc(l + 0.2) c h) in the dark theme. Measure its contrast with the background in both themes with a line you add to the script.

★★★ Write a --cols custom property for .book-list that uses typed arithmetic to work out how many columns fit, and use it to set the cards' title size: large in one column, small in four. Measure the values at 360, 768 and 1280px; see what changes without the @property registration.

In the next post we move on to two tools for writing component styles without a preprocessor like Sass: native CSS nesting and @scope. We will write Bookstore's card styles nested, stop styles from leaking into user content inside the card with @scope's donut scope, and compare it with Angular's emulated encapsulation, ASP.NET Core's CSS isolation and Shadow DOM.