Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

CSS  Module 6 – Colour and theming

color-mix(), relative colour and contrast-color()

Mix tints, shades and translucent states with color-mix(), derive colours from one token with relative colour syntax, and test contrast-color().

  • Intermediate
  • 30 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 30 minutes

What you will learn

  • Create tints, shades and translucent variants with color-mix()
  • Derive related colours from one base colour with the relative colour syntax
  • Diagnose a derived colour token that does not follow its base
  • Choose readable text over changing backgrounds with contrast-color() and know its limits

Before you start

What you’ll learn · Before you start

What you will learn

  • Create tints, shades and translucent variants with color-mix()
  • Derive related colours from one base colour with the relative colour syntax
  • Diagnose a derived colour token that does not follow its base
  • Choose readable text over changing backgrounds with contrast-color() and know its limits

Before you start

On this page

Three CSS functions turn one colour into many. color-mix() blends two colours in a colour space you name, in proportions you give: color-mix(in oklab, var(--brand), black 15%) is the brand colour with 15% black mixed in, a hover shade. The relative colour syntax takes a colour apart into its channels so you can rebuild it with changes: oklch(from var(--brand) calc(l - 0.1) c h) keeps the hue and the chroma and lowers the lightness. Its short form, alpha(), changes only the opacity. contrast-color() returns white or black, whichever the browser judges more readable as text on the colour you give it. Together they let a design system store one brand colour and derive its hover, pressed, quiet and border colours from it, for any brand.

This lesson measures each function in Chromium 153, Firefox 155 and WebKit 26.6, reproduces a token bug all three share, and finds out where contrast-color() can be trusted.

color-mix(): two colours, one colour space

color-mix() takes a colour space after in, then two colours, each with an optional percentage. The space decides how the two are averaged, and the same two colours give very different results:

Blue and yellow mixed in five colour spaces, and two newer forms HTML · mix-spaces.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>The same two colours mixed in five colour spaces, and two newer forms</title>
  <style>
    body { font: 16px/1.5 system-ui, sans-serif; margin: 16px; }
    .swatch { inline-size: 16rem; padding: 8px 12px; margin-block-end: 8px; background-color: lightgray; }
    /* Blue and yellow, half and half: only the colour space changes. */
    #srgb   { background-color: color-mix(in srgb, #0000ff, #ffff00); }
    #linear { background-color: color-mix(in srgb-linear, #0000ff, #ffff00); }
    #oklab  { background-color: color-mix(in oklab, #0000ff, #ffff00); }
    #oklch  { background-color: color-mix(in oklch, #0000ff, #ffff00); }
    #longer { background-color: color-mix(in oklch longer hue, #0000ff, #ffff00); }
    /* Two newer forms: a browser that rejects one keeps the lightgray of .swatch */
    #three  { background-color: color-mix(in oklab, #0000ff, #ffff00, #ff0000); }
    #nospace { background-color: color-mix(#0000ff, #ffff00); }
  </style>
</head>
<body>
  <div class="swatch" id="srgb">in srgb</div>
  <div class="swatch" id="linear">in srgb-linear</div>
  <div class="swatch" id="oklab">in oklab</div>
  <div class="swatch" id="oklch">in oklch</div>
  <div class="swatch" id="longer">in oklch longer hue</div>
  <div class="swatch" id="three">three colours</div>
  <div class="swatch" id="nospace">no colour space</div>
  <script>
    for (const el of document.querySelectorAll('.swatch')) {
      console.log(`${el.id.padEnd(8)} ${getComputedStyle(el).backgroundColor}`);
    }
  </script>
</body>
</html>

For the first five swatches the computed values were the same in all three engines apart from the last decimals, and so were the pixels each one painted:

Mixed in Painted
srgb #808080, a plain gray
srgb-linear #bcbcbc, a lighter gray
oklab #6cabc7, a dusty light blue
oklch #00cfbd, a vivid teal
oklch longer hue #ff3185, a hot pink

Averaging the gamma-encoded channels of srgb gives the muddy middle that people dislike in mixes. oklab averages lightness and the two colour axes, so a mix moves in a straight, even line between its colours, which is right for tints, shades and states. Polar spaces such as oklch average the hue as an angle: blue (264) and yellow (110) meet the shorter way round, at a teal near 187, and longer hue sends them the other way, through pink. Use oklch when a mix should travel round the colour wheel, and keep in mind that its vivid middle may fall outside sRGB: this teal was clipped.

color-mix() itself is Baseline widely available. The last two swatches use newer forms that are not yet safe. Mixing three or more colours has limited availability: Firefox and Safari 27 support it, and in this run Chromium 153 dropped the declaration and kept the light gray, while Firefox 155 and WebKit 26.6 mixed the three. The latest draft also lets you leave out in and mix in Oklab by default; all three engines accepted it and computed exactly the oklab result, but browser support data doesn’t list that form separately, so keep writing the colour space.

How the percentages work

The two percentages are made to add up to 100%, in a way that is easy to get wrong. Six mixes of the brand blue with white, or with transparent, show every rule:

How color-mix() reads its percentages HTML · mix-percentages.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>How color-mix() reads its percentages</title>
  <style>
    body { font: 16px/1.5 system-ui, sans-serif; margin: 16px; }
    .swatch { inline-size: 18rem; padding: 6px 12px; margin-block-end: 6px; }
    #given    { background-color: color-mix(in srgb, #0044cc 40%, white); }
    #none     { background-color: color-mix(in srgb, #0044cc, white); }
    #under    { background-color: color-mix(in srgb, #0044cc 30%, white 30%); }
    #over     { background-color: color-mix(in srgb, #0044cc 80%, white 80%); }
    #uneven   { background-color: color-mix(in srgb, #0044cc 70%, white 50%); }
    #see      { background-color: color-mix(in srgb, #0044cc 40%, transparent); }
  </style>
</head>
<body>
  <div class="swatch" id="given">#0044cc 40%, white</div>
  <div class="swatch" id="none">#0044cc, white</div>
  <div class="swatch" id="under">#0044cc 30%, white 30%</div>
  <div class="swatch" id="over">#0044cc 80%, white 80%</div>
  <div class="swatch" id="uneven">#0044cc 70%, white 50%</div>
  <div class="swatch" id="see">#0044cc 40%, transparent</div>
  <script>
    for (const el of document.querySelectorAll('.swatch')) {
      console.log(`${el.id.padEnd(6)} ${getComputedStyle(el).backgroundColor}`);
    }
  </script>
</body>
</html>

All three engines computed the same values:

  • #0044cc 40%, white: 40% and 60%, computed as color(srgb 0.6 0.706667 0.92).
  • #0044cc, white: 50% and 50%, color(srgb 0.5 0.633333 0.9).
  • #0044cc 30%, white 30%: 50% and 50% with an alpha of 0.6, color(srgb 0.5 0.633333 0.9 / 0.6).
  • #0044cc 80%, white 80%: 50% and 50%, color(srgb 0.5 0.633333 0.9).
  • #0044cc 70%, white 50%: 58.3% and 41.7%, color(srgb 0.416667 0.572222 0.883333).
  • #0044cc 40%, transparent: 40% and 60%, color(srgb 0 0.266667 0.8 / 0.4).

A missing percentage gets what the other leaves. Percentages above 100% in total are scaled down. Percentages below 100% in total are scaled up as well, but the missing part is kept and turns into transparency: 30%, 30% is a half-and-half mix with an alpha of 0.6, a translucent colour where most people expected a light blue.

The last row shows the other rule. Before averaging, each colour’s channels are multiplied by its alpha, which is called premultiplying, so transparent adds no colour at all, only its zero alpha. Mixing a colour with transparent therefore gives the same colour at a lower opacity, the most useful way to make a translucent variant of a token.

The steps of color-mix(): normalise the percentages, convert both colours, premultiply, average, divide, then apply the leftover.color-mix(in oklab, A 30%, B 30%)1. Percentages: a missing one gets the rest;scaled to add up to 100% (here 50% and 50%)What was missing from 100% is keptas a leftover (here 40%)2. Both colours convertedinto the mixing space3. Each colour's channelsmultiplied by its alpha4. Weighted average of channels and alphas(polar spaces: the shorter way round the hue)5. Divided by the new alpha again,then alpha × (1 − leftover) = 0.6

What a browser does for color-mix(), step by step

Text description of the diagram

The diagram follows color-mix(in oklab, A 30%, B 30%) through the steps of CSS Color 5, from top to bottom.

  1. The percentages are normalised. A missing percentage gets whatever the other leaves, and the two are scaled so that they add up to 100%. Here 30% and 30% become 50% and 50%.
  2. Whatever was missing from 100% before scaling is kept as a leftover: here 40%.
  3. Both colours are converted into the mixing space, here Oklab.
  4. Each colour's channels are multiplied by its own alpha (premultiplied), so a transparent colour adds no colour at all.
  5. The channels and the alphas are averaged with the two weights. In a polar space such as oklch, the hue goes the shorter way round the colour wheel unless another hue method is named.
  6. The averaged channels are divided by the new alpha again, and the alpha is multiplied by one minus the leftover. Here the result has an alpha of 0.6, so a mix whose percentages add up to less than 100% is translucent.

Hover, pressed and quiet states from one token

A button needs a colour for each state. Deriving all of them from one --brand value means a new brand, a new theme or a white-label customer needs one new value:

Button states from one brand colour HTML · button-states.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Button states from one brand colour</title>
  <style>
    body { font: 16px/1.5 system-ui, sans-serif; margin: 16px; display: grid; gap: 12px; justify-items: start; }
    .button {
      --brand: #0044cc;
      /* Every state is derived from --brand, so a new brand colour needs one new value. */
      --hover: color-mix(in oklab, var(--brand), black 15%);
      --pressed: color-mix(in oklab, var(--brand), black 30%);
      --subtle: color-mix(in oklab, var(--brand) 12%, white);
      --border: oklch(from var(--brand) calc(l - 0.1) c h);
      --ring: rgb(from var(--brand) r g b / 40%);
      padding: 8px 16px;
      font: inherit;
      color: white;
      background-color: var(--brand);
      border: 2px solid var(--border);
      border-radius: 6px;
    }
    .button:hover { background-color: var(--hover); }
    .button:active { background-color: var(--pressed); }
    .button:focus-visible { outline: 2px solid var(--brand); box-shadow: 0 0 0 6px var(--ring); }
    .button.quiet { color: var(--pressed); background-color: var(--subtle); }
    .orange { --brand: #f26b1d; color: #1a1a1a; }
    .green { --brand: #2e9d4f; }
  </style>
</head>
<body>
  <button class="button" type="button">Blue</button>
  <button class="button orange" type="button">Orange</button>
  <button class="button green" type="button">Green</button>
  <button class="button quiet" type="button">Quiet blue</button>
  <script>
    for (const button of document.querySelectorAll('.button')) {
      const s = getComputedStyle(button);
      console.log(button.textContent);
      for (const token of ['--brand', '--hover', '--pressed', '--subtle', '--border', '--ring']) {
        // Computed by giving each token to a real colour property, so the browser resolves it.
        const probe = document.createElement('span');
        probe.style.color = `var(${token})`;
        button.append(probe);
        console.log(`  ${token.padEnd(9)} ${getComputedStyle(probe).color}`);
        probe.remove();
      }
      console.log(`  background ${s.backgroundColor}, text ${s.color}`);
    }
  </script>
</body>
</html>

The three engines computed the same states and painted the same pixels: the blue’s hover state #0035a4 and pressed state #00267e, the quiet button’s background #e0eafb, and for the orange #c35515 and #963f0e. Mixing with black in oklab darkens a colour without dulling it; the same mixes in srgb gave the lighter #003aad and #00308f.

A derived colour can quietly break the contrast of whatever sits on it. The program below works the states out the way color-mix() does in oklab, then checks each button’s label:

The derived states and the contrast of their labels JavaScript · states.mjs
// The hover, pressed and quiet colours of button-states.html, worked out the way color-mix() does in oklab, and the
// contrast of each button's label on them. A label colour chosen for the brand colour may fail on its darker states.
// Conversions follow CSS Color 4 (sRGB, XYZ and Oklab); contrast follows WCAG 2.2.

const mul = (m, v) => m.map((row) => row[0] * v[0] + row[1] * v[1] + row[2] * v[2]);
const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const toGamma = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
const SRGB_TO_XYZ = [[0.4123908, 0.3575843, 0.1804808], [0.212639, 0.7151687, 0.0721923], [0.0193308, 0.1191948, 0.9505322]];
const XYZ_TO_SRGB = [[3.2409699, -1.5373832, -0.4986108], [-0.9692436, 1.8759675, 0.0415551], [0.0556301, -0.203977, 1.0569715]];
const XYZ_TO_LMS = [[0.8190224, 0.3619063, -0.1288738], [0.0329837, 0.9292869, 0.0361447], [0.0481772, 0.2642395, 0.6335478]];
const LMS_TO_OKLAB = [[0.2104543, 0.7936178, -0.004072], [1.9779985, -2.4285922, 0.4505937], [0.025904, 0.7827717, -0.8086758]];
const OKLAB_TO_LMS = [[1, 0.3963378, 0.2158038], [1, -0.1055613, -0.0638542], [1, -0.0894842, -1.2914855]];
const LMS_TO_XYZ = [[1.2268799, -0.557815, 0.281391], [-0.0405757, 1.1122868, -0.0717111], [-0.0763729, -0.4214933, 1.586924]];

const hexToRgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const rgbToHex = (rgb) => '#' + rgb.map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, '0')).join('');
const toOklab = (rgb) => mul(LMS_TO_OKLAB, mul(XYZ_TO_LMS, mul(SRGB_TO_XYZ, rgb.map(toLinear))).map(Math.cbrt));
const fromOklab = (lab) => mul(XYZ_TO_SRGB, mul(LMS_TO_XYZ, mul(OKLAB_TO_LMS, lab).map((x) => x ** 3))).map(toGamma);

/** color-mix(in oklab, a, b share) for two opaque hex colours, as hex. */
function mixOklab(a, b, share) {
  const [x, y] = [toOklab(hexToRgb(a)), toOklab(hexToRgb(b))];
  return rgbToHex(fromOklab(x.map((v, i) => v * (1 - share) + y[i] * share)));
}

const luminance = (rgb) => {
  const [r, g, b] = rgb.map(toLinear);
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const contrast = (a, b) => {
  const [hi, lo] = [luminance(hexToRgb(a)), luminance(hexToRgb(b))].sort((p, q) => q - p);
  return (hi + 0.05) / (lo + 0.05);
};

const buttons = [
  { name: 'blue', brand: '#0044cc', label: '#ffffff' },
  { name: 'orange', brand: '#f26b1d', label: '#1a1a1a' },
  { name: 'green', brand: '#2e9d4f', label: '#ffffff' },
];

console.log('button  state     colour    on white  on #1a1a1a  label');
for (const { name, brand, label } of buttons) {
  const states = [
    ['brand', brand],
    ['hover', mixOklab(brand, '#000000', 0.15)],
    ['pressed', mixOklab(brand, '#000000', 0.3)],
  ];
  for (const [state, colour] of states) {
    const white = contrast(colour, '#ffffff');
    const dark = contrast(colour, '#1a1a1a');
    const ratio = label === '#ffffff' ? white : dark;
    const cells = [name.padEnd(6), state.padEnd(8), colour, white.toFixed(2).padStart(8), dark.toFixed(2).padStart(10)];
    console.log(`${cells.join('  ')}  ${label} ${ratio >= 4.5 ? 'pass' : 'FAIL'}`);
  }
}
// The quiet button: --subtle (12% brand, 88% white) behind a label in --pressed.
const quiet = mixOklab('#0044cc', '#ffffff', 0.88);
const quietLabel = mixOklab('#0044cc', '#000000', 0.3);
const quietRatio = contrast(quiet, quietLabel);
console.log(`quiet button: ${quietLabel} text on ${quiet}, ${quietRatio.toFixed(2)} ${quietRatio >= 4.5 ? 'pass' : 'FAIL'}`);

Output

button  state     colour    on white  on #1a1a1a  label
blue    brand     #0044cc      7.78        2.24  #ffffff pass
blue    hover     #0035a4     10.27        1.70  #ffffff pass
blue    pressed   #00267e     13.30        1.31  #ffffff pass
orange  brand     #f26b1d      3.05        5.72  #1a1a1a pass
orange  hover     #c35515      4.54        3.84  #1a1a1a FAIL
orange  pressed   #963f0e      6.97        2.50  #1a1a1a FAIL
green   brand     #2e9d4f      3.47        5.02  #ffffff FAIL
green   hover     #237e3e      5.09        3.42  #ffffff pass
green   pressed   #185f2e      7.73        2.25  #ffffff pass
quiet button: #00267e text on #e0eafb, 10.98 pass

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node states.mjs

Its colours match what the engines painted, digit for digit. The blue gets more readable as it darkens. The orange, whose dark label suits the bright brand colour, falls to 3.84:1 on hover and 2.50:1 when pressed, so its darker states need the white label instead, which reaches 4.54:1 on hover and 6.97:1 when pressed. The green fails from the start with white text, at 3.47:1, where the dark label would reach 5.02:1, and white passes only from its hover state on. color-mix() knows nothing about the text you put on a colour, so every derived state needs its own contrast check, and a label colour that can switch per state.

Color Mixer Mix two colours in OKLab, OKLCH or sRGB at any ratio and copy the color-mix() it corresponds to.

Relative colours: take a colour apart

The relative colour syntax puts from and an origin colour at the start of a colour function. The origin is converted into that function’s colour space, and its channels become keywords you can use in each slot: r g b in rgb(), h s l in hsl(), l c h in oklch(), and alpha everywhere. Inside calc() they are plain numbers:

Relative colours from one brand colour HTML · relative.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Relative colours from one brand colour</title>
  <style>
    body { font: 16px/1.5 system-ui, sans-serif; margin: 16px; }
    :root { --brand: #0044cc; }
    .swatch { inline-size: 18rem; padding: 6px 12px; margin-block-end: 6px; }
    #darker     { background-color: oklch(from var(--brand) calc(l - 0.1) c h); }
    #muted      { background-color: oklch(from var(--brand) l calc(c / 2) h); }
    #opposite   { background-color: oklch(from var(--brand) l c calc(h + 180)); }
    #lighter    { background-color: hsl(from var(--brand) h s calc(l + 20)); }
    #see-rgb    { background-color: rgb(from var(--brand) r g b / 40%); }
    #see-alpha  { background-color: alpha(from var(--brand) / 40%); }
  </style>
</head>
<body>
  <div class="swatch" id="darker">darker: l - 0.1</div>
  <div class="swatch" id="muted">muted: half the chroma</div>
  <div class="swatch" id="opposite">opposite hue: h + 180</div>
  <div class="swatch" id="lighter">hsl, lightness + 20</div>
  <div class="swatch" id="see-rgb">rgb(), alpha 40%</div>
  <div class="swatch" id="see-alpha">alpha(), 40%</div>
  <script>
    for (const el of document.querySelectorAll('.swatch')) {
      console.log(`${el.id.padEnd(9)} ${getComputedStyle(el).backgroundColor}`);
    }
  </script>
</body>
</html>
  • Darker: oklch(from var(--brand) calc(l - 0.1) c h) lowers the lightness and keeps the hue and chroma.
  • Muted: calc(c / 2) halves the chroma, #345492 for the brand blue.
  • Opposite hue: calc(h + 180) turns the blue into a brown-orange, #8e3d00.
  • In HSL: hsl(from var(--brand) h s calc(l + 20)) raises the HSL lightness by 20 points, #3377ff.
  • Translucent: rgb(from var(--brand) r g b / 40%) keeps the channels and sets the alpha.

Two details of the run are worth knowing. The darker blue fell outside sRGB, because a vivid colour that keeps its chroma as it darkens leaves the gamut; all three engines clipped it to #001daa. When a derived shade must stay inside sRGB, lower the chroma with the lightness, for example with calc(c * 0.8). And the computed values differed in their last decimals: the darker blue’s hue was 262.452 in Chromium, 262.45 in Firefox and 262.44986 in WebKit, so tests should compare colours with a tolerance, never as exact strings.

alpha(from var(--brand) / 40%) is the short form for changing only the opacity, and its result stays in the origin colour’s space. Chromium and WebKit reported it as rgba(0, 68, 204, 0.4), Firefox as color(srgb 0 0.266667 0.8 / 0.4), the same colour written two ways. Relative colours are Baseline newly available; alpha() became newly available most recently, so give it a fallback such as color-mix(in srgb, var(--brand) 40%, transparent) if older browsers matter to you.

The token trap: derived colours don’t follow their base

Design tokens are often declared once, on :root. Then a component overrides the base colour and expects its derived colours to follow:

A derived colour token that doesn't follow its base HTML · token-scope.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A derived colour token that doesn't follow its base</title>
  <style>
    body { font: 16px/1.5 system-ui, sans-serif; margin: 16px; }
    /* Both tokens are declared on :root, so --brand-dark is worked out once, from the blue. */
    :root {
      --brand: #0044cc;
      --brand-dark: color-mix(in oklab, var(--brand), black 30%);
    }
    .card { padding: 12px; margin-block-end: 12px; border: 4px solid var(--brand-dark); }
    /* Overriding only --brand here does not change --brand-dark, which arrives already computed. */
    .card.green { --brand: #2e9d4f; }
    /* The fix: work the derived token out where the base changes, here on every card. */
    .card.fixed { --brand-dark: color-mix(in oklab, var(--brand), black 30%); }
  </style>
</head>
<body>
  <div class="card" id="blue">Blue card</div>
  <div class="card green" id="green">Green card, --brand overridden</div>
  <div class="card green fixed" id="fixed">Green card, derived token declared on the card</div>
  <script>
    for (const card of document.querySelectorAll('.card')) {
      console.log(`${card.id.padEnd(6)} border ${getComputedStyle(card).borderTopColor}`);
    }
  </script>
</body>
</html>

In all three engines the green card’s border stayed dark blue: oklab(0.317647 -0.020002 -0.15091) in Firefox and WebKit, the same colour as the blue card’s. A custom property replaces its var() references where it is declared, so --brand-dark was built from the blue on :root, and every element below inherits that finished value. Overriding --brand lower down changes nothing that was already built. The fix in the third card is to declare the derived token where the base changes, in the component’s own rule; then each card builds it from its own --brand, and the third card’s border computed to a dark green, oklab(0.431083 -0.091083 0.05427) in WebKit.

contrast-color(): white or black text

contrast-color(<color>) returns white or black, whichever gives more contrast as text on that colour used as a solid background, and white when the two are equal. CSS Color 5 leaves the exact algorithm to the browser, advises browsers not to use the WCAG 2 contrast formula, which has known weaknesses, and asks only that the result meet 3:1, the WCAG target for large text. It is Baseline newly available, and it is one of the focus areas of Interop 2026, the yearly plan in which browser makers agree to make features work the same everywhere. Twenty opaque badges and one translucent one show what the engines do with it:

contrast-color() on twenty-one badge backgrounds HTML · badges.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>contrast-color() on twenty-one badge backgrounds</title>
  <style>
    body { font: 14px/1.5 system-ui, sans-serif; margin: 16px; display: flex; flex-wrap: wrap; gap: 8px; }
    .badge {
      padding: 2px 10px;
      border-radius: 999px;
      background-color: var(--bg);
      color: #1a1a1a;                   /* for browsers without contrast-color() */
      color: contrast-color(var(--bg)); /* white or black, whichever the browser judges more readable */
    }
  </style>
</head>
<body>
  <span class="badge" style="--bg: #0044cc">#0044cc</span>
  <span class="badge" style="--bg: #f26b1d">#f26b1d</span>
  <span class="badge" style="--bg: #2e9d4f">#2e9d4f</span>
  <span class="badge" style="--bg: #e11d48">#e11d48</span>
  <span class="badge" style="--bg: #7c3aed">#7c3aed</span>
  <span class="badge" style="--bg: #0891b2">#0891b2</span>
  <span class="badge" style="--bg: #ca8a04">#ca8a04</span>
  <span class="badge" style="--bg: #db2777">#db2777</span>
  <span class="badge" style="--bg: #4b5563">#4b5563</span>
  <span class="badge" style="--bg: #808080">#808080</span>
  <span class="badge" style="--bg: #3b82f6">#3b82f6</span>
  <span class="badge" style="--bg: #ff7f50">#ff7f50</span>
  <!-- Eight grays around the point where the better of black and white changes -->
  <span class="badge" style="--bg: #727272">#727272</span>
  <span class="badge" style="--bg: #737373">#737373</span>
  <span class="badge" style="--bg: #747474">#747474</span>
  <span class="badge" style="--bg: #757575">#757575</span>
  <span class="badge" style="--bg: #767676">#767676</span>
  <span class="badge" style="--bg: #777777">#777777</span>
  <span class="badge" style="--bg: #787878">#787878</span>
  <span class="badge" style="--bg: #797979">#797979</span>
  <!-- A translucent background: the function sees only this colour, not the page behind it -->
  <span class="badge" style="--bg: rgb(0 0 0 / 50%)">translucent black</span>
  <script>
    for (const badge of document.querySelectorAll('.badge')) {
      console.log(`${badge.textContent} -> ${getComputedStyle(badge).color}`);
    }
  </script>
</body>
</html>

All three engines made identical choices. The program below checks each one with the WCAG 2 ratio:

Each choice of contrast-color(), checked with WCAG 2 JavaScript · badge_check.mjs
// What contrast-color() chose on the twenty badges of badges.html, checked with the WCAG 2.2 ratio. The choices are
// the ones Chromium 153, Firefox 155 and WebKit 26.6 all made in this lesson's run ("w" white, "b" black).

const picks = {
  '#0044cc': 'w', '#f26b1d': 'b', '#2e9d4f': 'b', '#e11d48': 'w', '#7c3aed': 'w', '#0891b2': 'b',
  '#ca8a04': 'b', '#db2777': 'w', '#4b5563': 'w', '#808080': 'b', '#3b82f6': 'b', '#ff7f50': 'b',
  '#727272': 'w', '#737373': 'w', '#747474': 'w', '#757575': 'w', '#767676': 'b', '#777777': 'b', '#787878': 'b', '#797979': 'b',
};

const luminance = (hex) => {
  const lin = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
  return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
};

let agree = 0;
let lowest = { ratio: Infinity, hex: '' };
console.log('background  on white  on black  chosen  ratio');
for (const [hex, pick] of Object.entries(picks)) {
  const y = luminance(hex);
  const onWhite = 1.05 / (y + 0.05);
  const onBlack = (y + 0.05) / 0.05;
  const chosen = pick === 'w' ? onWhite : onBlack;
  if ((onWhite >= onBlack ? 'w' : 'b') === pick) agree++;
  if (chosen < lowest.ratio) lowest = { ratio: chosen, hex };
  console.log(`${hex}     ${onWhite.toFixed(2).padStart(8)}  ${onBlack.toFixed(2).padStart(8)}  ${pick === 'w' ? 'white' : 'black'}   ${chosen.toFixed(2)}`);
}
console.log(`choices that were the better of white and black: ${agree} of ${Object.keys(picks).length}`);
console.log(`lowest ratio chosen: ${lowest.ratio.toFixed(2)} on ${lowest.hex}`);

// The worst background for "the better of white and black" is the one where both are equal:
// 1.05 / (y + 0.05) = (y + 0.05) / 0.05, so y + 0.05 = sqrt(1.05 * 0.05).
const worst = Math.sqrt(1.05 * 0.05);
console.log(`worst case for any opaque background: luminance ${(worst - 0.05).toFixed(4)}, ratio ${(worst / 0.05).toFixed(2)}`);

// The translucent badge: contrast-color(rgb(0 0 0 / 50%)) returned white in all three engines, but over the white
// page that badge was painted #7f7f7f (the pixel read from each engine's screenshot of badges.html).
const painted = '#7f7f7f';
const y = luminance(painted);
console.log(`translucent black badge, painted ${painted}: white text ${(1.05 / (y + 0.05)).toFixed(2)}, black text ${((y + 0.05) / 0.05).toFixed(2)}`);

Output

background  on white  on black  chosen  ratio
#0044cc         7.78      2.70  white   7.78
#f26b1d         3.05      6.90  black   6.90
#2e9d4f         3.47      6.05  black   6.05
#e11d48         4.70      4.47  white   4.70
#7c3aed         5.70      3.69  white   5.70
#0891b2         3.68      5.70  black   5.70
#ca8a04         2.94      7.15  black   7.15
#db2777         4.60      4.57  white   4.60
#4b5563         7.56      2.78  white   7.56
#808080         3.95      5.32  black   5.32
#3b82f6         3.68      5.71  black   5.71
#ff7f50         2.50      8.40  black   8.40
#727272         4.81      4.37  white   4.81
#737373         4.74      4.43  white   4.74
#747474         4.67      4.49  white   4.67
#757575         4.61      4.56  white   4.61
#767676         4.54      4.62  black   4.62
#777777         4.48      4.69  black   4.69
#787878         4.42      4.76  black   4.76
#797979         4.35      4.82  black   4.82
choices that were the better of white and black: 20 of 20
lowest ratio chosen: 4.60 on #db2777
worst case for any opaque background: luminance 0.1791, ratio 4.58
translucent black badge, painted #7f7f7f: white text 4.00, black text 5.24

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node badge_check.mjs

Every choice was the better of white and black by the WCAG 2 ratio, even on #db2777, where white wins by 4.60 to 4.57, and on the grays, where the choice flipped from white to black between #757575 and #767676, exactly where the ratio flips. The last line explains why the result passes: the background where white and black do equally badly has a luminance of 0.1791, and even there both reach 4.58:1. So whenever a browser picks the WCAG-better colour, text on an opaque background reaches 4.5:1.

Three limits remain. The algorithm is the browser’s choice and may change, since the specification only aims at 3:1, so keep a contrast test for body text. The function sees only the colour you give it: on the translucent badge, rgb(0 0 0 / 50%), all three engines returned white, and painted over the white page that badge is a mid-gray #7f7f7f, where white text reaches only about 4.0:1. And it never returns a brand colour, only white or black. In browsers without support, the plain color declaration before it applies.

Color Contrast Checker (WCAG 2.2) Check a generated background against white and black text when contrast-color() is not enough.

Practise: what color-mix() returns

Write the percentage rules and the premultiplied mix yourself, and compare your results with the values the three engines computed:

Exercise · Medium · JavaScript

Work out what color-mix() returns in sRGB

color-mix() surprises people in two places: percentages that don't add up to 100%, and translucent colours. Write two functions in mix.mjs and export them, following CSS Color 5.

The first, normalise(p1, p2), takes the two percentages of a mix as numbers from 0 to 100, or null when one was left out, and returns { w1, w2, alpha }: the two weights as fractions that add up to 1, and the factor the result's alpha is multiplied by.

- Both left out: half each. One left out: it gets what the other leaves, 100 - p. - If the two add up to more than 100, both are scaled down so they add up to 100, and alpha is 1. - If they add up to less than 100, both are scaled up to add up to 100, and alpha is their sum divided by 100: color-mix(in srgb, red 30%, blue 30%) is a half-and-half mix with an alpha of 0.6. - A percentage below 0 or above 100 throws a RangeError. You may assume the two never add up to 0.

The second, mixSrgb(a, p1, b, p2), mixes two colours in the srgb space. A colour is { r, g, b, alpha } with every value from 0 to 1. Mix the way browsers do: multiply each colour's channels by its alpha, take the weighted average of those and of the alphas, divide the channels by the mixed alpha (when it is 0, return channels of 0), and finally multiply the alpha by the factor from normalise. That is why mixing with transparent changes only the alpha.

The sample tests import both functions from mix.mjs, compare them with what Chromium, Firefox and WebKit computed, and run in your browser.

Starter code · mix.mjs

/** The weights of a color-mix() and the factor its alpha is multiplied by; null marks a percentage left out. */
export function normalise(p1, p2) {
  // Replace this line with your code.
  return { w1: 0.5, w2: 0.5, alpha: 1 };
}

/** color-mix(in srgb, a p1, b p2) for colours { r, g, b, alpha } with values from 0 to 1. */
export function mixSrgb(a, p1, b, p2) {
  // Replace this line with your code.
  return { ...a };
}
The sample tests · mix.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { normalise, mixSrgb } from './mix.mjs';

const close = (actual, expected, label) => {
  for (const key of Object.keys(expected)) {
    assert.ok(Math.abs(actual[key] - expected[key]) < 0.0005, `${label}: ${key} is ${actual[key]}, expected ${expected[key]}`);
  }
};
const blue = { r: 0, g: 68 / 255, b: 204 / 255, alpha: 1 };
const white = { r: 1, g: 1, b: 1, alpha: 1 };
const transparent = { r: 0, g: 0, b: 0, alpha: 0 };

test('percentages that are left out', () => {
  close(normalise(null, null), { w1: 0.5, w2: 0.5, alpha: 1 }, 'both left out');
  close(normalise(40, null), { w1: 0.4, w2: 0.6, alpha: 1 }, '40% and nothing');
  close(normalise(null, 25), { w1: 0.75, w2: 0.25, alpha: 1 }, 'nothing and 25%');
});

test('more than 100% is scaled down, less than 100% also lowers the alpha', () => {
  close(normalise(80, 80), { w1: 0.5, w2: 0.5, alpha: 1 }, '80% and 80%');
  close(normalise(70, 50), { w1: 70 / 120, w2: 50 / 120, alpha: 1 }, '70% and 50%');
  close(normalise(30, 30), { w1: 0.5, w2: 0.5, alpha: 0.6 }, '30% and 30%');
  close(normalise(10, 30), { w1: 0.25, w2: 0.75, alpha: 0.4 }, '10% and 30%');
});

test('percentages outside 0 to 100 throw a RangeError', () => {
  assert.throws(() => normalise(-10, 50), RangeError);
  assert.throws(() => normalise(50, 120), RangeError);
});

test('opaque colours, as the three engines computed them', () => {
  // color-mix(in srgb, #0044cc 40%, white) computed to color(srgb 0.6 0.706667 0.92)
  close(mixSrgb(blue, 40, white, null), { r: 0.6, g: 0.706667, b: 0.92, alpha: 1 }, '#0044cc 40%, white');
  // color-mix(in srgb, #0044cc 70%, white 50%) computed to color(srgb 0.416667 0.572222 0.883333)
  close(mixSrgb(blue, 70, white, 50), { r: 0.416667, g: 0.572222, b: 0.883333, alpha: 1 }, '#0044cc 70%, white 50%');
});

test('percentages under 100% give a translucent result', () => {
  // color-mix(in srgb, #0044cc 30%, white 30%) computed to color(srgb 0.5 0.633333 0.9 / 0.6)
  close(mixSrgb(blue, 30, white, 30), { r: 0.5, g: 0.633333, b: 0.9, alpha: 0.6 }, '#0044cc 30%, white 30%');
});

test('mixing with transparent changes only the alpha', () => {
  // color-mix(in srgb, #0044cc 40%, transparent) computed to color(srgb 0 0.266667 0.8 / 0.4)
  close(mixSrgb(blue, 40, transparent, null), { r: 0, g: 0.266667, b: 0.8, alpha: 0.4 }, '#0044cc 40%, transparent');
  close(mixSrgb(transparent, null, transparent, null), { r: 0, g: 0, b: 0, alpha: 0 }, 'transparent with transparent');
});

test('two translucent colours, the example of CSS Color 5', () => {
  const red = { r: 1, g: 0, b: 0, alpha: 0.7 };
  const green = { r: 0, g: 1, b: 0, alpha: 0.2 };
  close(mixSrgb(red, 25, green, null), { r: 0.53846, g: 0.46154, b: 0, alpha: 0.325 }, 'red / 0.7 25%, green / 0.2');
});
A hint

In normalise, first replace a missing percentage: both missing become 50 and 50, one missing becomes 100 - other. Then let sum = p1 + p2: the weights are always p1 / sum and p2 / sum, and the alpha factor is Math.min(1, sum / 100). In mixSrgb, the mixed alpha is a.alpha * w1 + b.alpha * w2, and the red channel is (a.r * a.alpha * w1 + b.r * b.alpha * w2) / mixedAlpha.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 Pure blue and pure yellow mixed half and half: in which colour space did all three engines paint a plain gray, #808080?

    Choose one answer.

    Show the answer to question 1

    Answer: srgb

    In srgb the channels of #0000ff and #ffff00 average to 0.5 each, a gray. srgb-linear gave a lighter gray, oklab a dusty blue (#6cabc7), oklch a vivid teal (#00cfbd), because its hue took the shorter way round the wheel, and oklch longer hue a pink (#ff3185).

  2. Question 2 of 6 What does color-mix(in srgb, #0044cc 30%, white 30%) produce?

    Choose one answer.

    Show the answer to question 2

    Answer: A half-and-half mix with an alpha of 0.6, so the page behind shows through

    Percentages that add up to less than 100% are scaled up to 100% (50% each here), and the missing 40% becomes transparency: the result's alpha is multiplied by 0.6. All three engines computed color(srgb 0.5 0.633333 0.9 / 0.6).

  3. Question 3 of 6 All three engines computed color-mix(in srgb, #0044cc 40%, transparent) as the same blue with a different alpha. What alpha?

    Type a number.

    Show the answer to question 3

    Answer: 0.4 (anything from 0.399 to 0.401 counts)

    Colours are multiplied by their alpha before they are mixed, so transparent adds no colour, only its alpha of 0. The result is color(srgb 0 0.266667 0.8 / 0.4): the brand blue at 40% opacity, the same colour as rgb(from #0044cc r g b / 40%) and alpha(from #0044cc / 40%).

  4. Question 4 of 6 What colour is the green card's border?

    Read the code, then choose one answer.

    :root {
      --brand: #0044cc;
      --brand-dark: color-mix(in oklab, var(--brand), black 30%);
    }
    .card { border: 4px solid var(--brand-dark); }
    .card.green { --brand: #2e9d4f; }
    Show the answer to question 4

    Answer: The dark blue: --brand-dark was worked out on :root and inherited as a finished value

    A custom property's var() references are replaced where the property is declared, here on :root, so every element inherits --brand-dark already built from the blue. All three engines drew the green card's border in the dark blue. Declaring --brand-dark in the .card rule, where --brand changes, fixes it.

  5. Question 5 of 6 oklch(from #0044cc calc(l - 0.1) c h) lowers only the lightness. What happened to it in the lesson's run?

    Choose one answer.

    Show the answer to question 5

    Answer: It fell outside sRGB, so the engines clipped it and painted #001daa

    A vivid blue that keeps its chroma while getting darker leaves the sRGB gamut: its red channel would be about −0.05. All three engines clipped it to #001daa. Lowering the chroma with the lightness, for example calc(c * 0.8), keeps such a shade inside sRGB.

  6. Question 6 of 6 Which statements about contrast-color() held in the lesson's run or in CSS Color 5? Choose every one.

    Choose every answer that is right.

    Show the answer to question 6

    Answer:

    • All three engines chose the colour with the higher WCAG 2 ratio on all twenty opaque badges
    • It returned only white or black

    The function returns white or black, and today's engines picked the WCAG-better one every time, which always reaches at least 4.58:1 on an opaque background. But the specification leaves the algorithm to the browser and only aims at 3:1, the target for large text, and contrast-color(rgb(0 0 0 / 50%)) returned white without knowing what is behind the colour.

Interview questions

Fresher · What does color-mix(in oklab, var(--brand), black 15%) produce, and why name the colour space?

It blends the brand colour with black: black gets 15%, and the brand colour gets the rest, 85%, so the result is a slightly darker shade of the brand, which suits a hover state. The colour space says how the two are averaged. In oklab the mix moves evenly in lightness and keeps the hue, while srgb averages gamma-encoded channels, which gives muddier midpoints: pure blue and pure yellow mixed half and half came out as a plain gray in srgb and a dusty blue in oklab. Polar spaces such as oklch mix the hue as an angle and can travel round the colour wheel. Naming the space also keeps the result predictable in every browser, since leaving it out is a newer form.

Key takeaways

  • color-mix(in oklab, A p%, B) blends evenly; srgb muddies the middle and oklch travels round the hue wheel.
  • Percentages that add up to less than 100% make the result translucent; mixing with transparent changes only the alpha.
  • Relative colours rebuild a colour from its channels: oklch(from var(--brand) calc(l - 0.1) calc(c * 0.8) h); lower the chroma with the lightness to stay inside sRGB.
  • Declare derived tokens where their base changes: var() is replaced where a custom property is declared.
  • contrast-color() returns white or black; today’s engines pick the WCAG-better one, but test body text anyway and never trust it on a translucent background.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.