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().
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:
<!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:
<!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 ascolor(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.
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.
- 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%.
- Whatever was missing from 100% before scaling is kept as a leftover: here 40%.
- Both colours are converted into the mixing space, here Oklab.
- Each colour's channels are multiplied by its own alpha (premultiplied), so a transparent colour adds no colour at all.
- 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.
- 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:
<!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 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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.
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:
<!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,#345492for 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:
<!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:
<!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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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.
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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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;srgbmuddies the middle andoklchtravels round the hue wheel.- Percentages that add up to less than 100% make the result translucent; mixing with
transparentchanges 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
- CSS Color Module Level 5: mixing colours with color-mix() (W3C CSS Working Group)
- CSS Color Module Level 5: relative colours (W3C CSS Working Group)
- CSS Color Module Level 5: the contrast-color() function (W3C CSS Working Group)
- CSS Values and Units Module Level 5: normalizing mix percentages (W3C CSS Working Group)
- CSS Color Module Level 4: colour interpolation (W3C CSS Working Group)
- CSS Custom Properties for Cascading Variables Module Level 1: substituting var() (W3C CSS Working Group)
- color-mix() (MDN Web Docs) (Mozilla)
- Using relative colors (MDN Web Docs) (Mozilla)
- contrast-color() (MDN Web Docs) (Mozilla)
- color-mix() (web-features explorer) (W3C WebDX Community Group)
- Relative colors (web-features explorer) (W3C WebDX Community Group)
- alpha() (web-features explorer) (W3C WebDX Community Group)
- contrast-color() (web-features explorer) (W3C WebDX Community Group)
- Interop 2026 (web-platform-tests Interop team)
- Web Content Accessibility Guidelines (WCAG) 2.2: contrast ratio (W3C)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress