A Second Look at a First-Party CSS Answer for Color Contrast
Web accessibility is often a legal requirement, not just a best practice. Beyond compliance, ensuring readable text matters for everyone. While numerous tools exist to check color contrast against WCAG benchmarks, the CSS contrast-color() function takes a different approach: it doesn't audit your colors — it resolves a foreground color for you, picking either black or white, whichever creates the higher contrast against your specified background.
button {
--background-color: darkblue;
background-color: var(--background-color);
color: contrast-color(var(--background-color));
}
In this example, contrast-color() resolves to white because white offers better contrast against darkblue than black does. That's the simple version. However, for all its promise, the feature remains in a fairly early state.
@supports (color: contrast-color(red)) {
/* contrast-color() supported */
}
@supports not (color: contrast-color(red)) {
/* contrast-color() not supported */
}
Current Limitations
Perhaps the most obvious restriction is that contrast-color() only ever returns one of two values: black or white. If neither works with your design, the function won't help you. The draft spec does hint at future flexibility here, but for now, it's binary.
More critically — and easy to overlook — is that neither black nor white is guaranteed to be accessible. There are plenty of mid-tone colors where both black and white fail WCAG contrast requirements. In those cases, contrast-color() resolves to a color that is technically rendered but not actually accessible. Ideally, the function would pick the closest accessible variation of a preferred color, but until then, it isn't reliably usable.
The function also only accepts arguments of the <color> data type, so gradients and images are out of scope. You can fake a gradient with multiple calls to contrast-color() at different color stops, but the result is poor — in the middle of a gradient, you get roughly 50% grey on purple, a textbook contrast failure.
<button>
<span>A button</span>
</button>
button {
background: linear-gradient(to right, red, blue);
span {
background: linear-gradient(to right, contrast-color(red), contrast-color(blue));
color: transparent;
background-clip: text;
}
}
Nor does contrast-color() account for font size or weight. WCAG's contrast guidelines differ for larger text, but the function has no way of knowing how the color will be used. That's a major gap. It's safe to assume the spec will eventually need to account for text dimensions, which brings us to the broader debate around contrast measurement.
The Algorithm Question: WCAG vs. APCA
Current contrast checks are based on the WCAG 2 algorithm, which has known inaccuracies. The Accessible Perceptual Contrast Algorithm (APCA) is a proposed successor designed to measure contrast more reliably. Research led by APCA's creator, Andrew Somers, found that roughly 23% of WCAG 2 "Fail" results are actually accessible, and a startling 47% of "Pass" results are inaccessible.
APCA's Readability Criterion is more nuanced than WCAG 2, accounting for a wider range of font sizes and weights. Currently, the contrast-color() function only uses the WCAG 2 algorithm. The draft spec suggests that additional algorithms might be offered in the future, allowing authors to choose between APCA and WCAG. That distinction matters because different jurisdictions have different legal requirements — some mandate WCAG 2 compliance, while others may eventually require WCAG 3.
Realistically, APCA isn't close to becoming part of a ratified WCAG 3 standard, and contrast-color() is even further out. There's even debate about whether APCA will be included in WCAG 3's initial release. For now, we work with what we have: WCAG 2 and a function that can only say "black" or "white."
Practical Usage Patterns
Setting those limitations aside, the function is straightforward to use. A common pattern pairs it with a CSS custom property to keep color definitions in one place:
button {
--background-color: darkblue;
background-color: var(--background-color);
/* Resolves to white */
color: contrast-color(var(--background-color));
}
button {
--background-color: lightblue;
background-color: var(--background-color);
/* Resolves to black */
color: contrast-color(var(--background-color));
}
While it naturally works on text, contrast-color() can accept any <color> context, including background-color and borders:
button {
--color: darkblue;
color: var(--color);
/* Resolves to white */
background-color: contrast-color(var(--color));
}
Any valid color — named, HEX, RGB, HSL, HWB — works as an argument, and the base color can change dynamically on hover:
button {
/* HSL this time */
--background-color: hsl(0 0% 0%);
background-color: var(--background-color);
/* Resolves to white */
color: contrast-color(var(--background-color));
}
button {
--background-color: hsl(0 0% 0%);
background-color: var(--background-color);
/* Starts off white, becomes black on hover */
color: contrast-color(var(--background-color));
&:hover {
/* 50% lighter */
--background-color: hsl(0 0% 50%);
}
}
You can also combine it with light-dark() to manage contrast in light and dark modes:
:root {
/* Dark mode if checked */
&:has(input[type="checkbox"]:checked) {
color-scheme: dark;
}
/* Light mode if not checked */
&:not(:has(input[type="checkbox"]:checked)) {
color-scheme: light;
}
body {
/* Different background for each mode */
background: light-dark(hsl(0 0% 50%), hsl(0 0% 0%));
/* Different contrasted color for each mode */
color: light-dark(contrast-color(hsl(0 0% 50%)), contrast-color(hsl(0 0% 0%));
}
}
This pattern is particularly relevant because the WCAG 2 algorithm often evaluates dark mode contrast poorly, while APCA accounts for the brightness discrepancies between light and dark themes.
One approach that keeps design intent intact is to reserve contrast-color() for the prefers-contrast: more media query, applying your hand-picked colors in no-preference and letting CSS handle contrast only when the user explicitly requests it:
button {
--background-color: hsl(270 100% 50%);
background-color: var(--background-color);
/* Almost white (WCAG AA: Fail) */
color: hsl(270 100% 90%);
@media (prefers-contrast: more) {
/* Resolves to white (WCAG AA: Pass) */
color: contrast-color(var(--background-color));
}
}
That said, treating contrast as an "enhancement" for users who ask for it is not ideologically sound. Good contrast benefits everyone, and relying on users to configure their systems — or even knowing how to — is a fragile assumption.
Where That Leaves Us
In its current form, contrast-color() is not ready for production: it only resolves to black or white, and even then it doesn't guarantee accessibility. A more useful implementation would resolve to the nearest accessible color from a wider spectrum — upgrading a fallback color to meet WCAG AA if it fails, but leaving it alone otherwise. That would require the browser to know font size, weight, and context.
There are alternative models. Windows High Contrast Mode, for example, applies forced-colors: active, which instructs browsers to overwrite colors entirely. Authors can opt out with the forced-colors-adjust property and define their own accessible palette. It's a heavy-handed solution, and macOS doesn't extend its equivalent setting to the web at all. A ideal system would let users specify contrast preferences during setup but ultimately hold designers accountable: systems shouldn't have to override colors that are accessible in the first place.
The open question is who bears the responsibility for accessibility — individual authors with tools like contrast-color(), or user agents with enforced color systems. Whatever the answer, manual color checking alone won't scale.



