Thu, 05 Jun 2025 13:45:56 +0000
In many countries, web accessibility is a human right and the law, and there can be heavy fines for non-compliance. Naturally, this means that text and icons and such must have optimal color contrast in accordance with the benchmarks set by the Web Content Accessibility Guidelines (WCAG). Now, there are quite a few color contrast checkers out there (Figma even has one built-in now), but the upcoming contrast-color()
function doesn’t check color contrast, it outright resolves to either black or white (whichever one contrasts the most with your chosen color).
Right off the bat, you should know that we’ve sorta looked at this feature before. Back then, however, it was called color-contrast()
instead of contrast-color()
and had a much more convoluted way of going about things. It was only released in Safari Technology Preview 122 back in 2021, and that’s still the case at the time I’m writing this (now at version 220).
You’d use it like this:
button {
--background-color: darkblue;
background-color: var(--background-color);
color: contrast-color(var(--background-color));
}
Here, contrast-color()
has determined that white contrasts with darkblue
better than black does, which is why contrast-color()
resolves to white
. Pretty simple, really, but there are a few shortcomings, which includes a lack of browser support (again, it’s only in Safari Technology Preview at the moment).
We can use contrast-color()
conditionally, though:
@supports (color: contrast-color(red)) {
/* contrast-color() supported */
}
@supports not (color: contrast-color(red)) {
/* contrast-color() not supported */
}
The shortcomings of contrast-color()
First, let me just say that improvements are already being considered, so here I’ll explain the shortcomings as well as any improvements that I’ve heard about.
Undoubtedly, the number one shortcoming is that contrast-color()
only resolves to either black or white. If you don’t want black or white, well… that sucks. However, the draft spec itself alludes to more control over the resolved color in the future.
But there’s one other thing that’s surprisingly easy to overlook. What happens when neither black nor white is actually accessible against the chosen color? That’s right, it’s possible for contrast-color()
to just… not provide a contrasting color. Ideally, I think we’d want contrast-color()
to resolve to the closest accessible variant of a preferred color. Until then, contrast-color()
isn’t really usable.
Another shortcoming of contrast-color()
is that it only accepts arguments of the <color>
data type, so it’s just not going to work with images or anything like that. I did, however, manage to make it “work” with a gradient (basically, two instances of contrast-color()
for two color stops/one linear gradient):
<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;
}
}
The reason this looks so horrid is that, as mentioned before, contrast-color()
only resolves to black or white, so in the middle of the gradient we essentially have 50% grey on purple. This problem would also get solved by contrast-color()
resolving to a wider spectrum of colors.
But what about the font size? As you might know already, the criteria for color contrast depends on the font size, so how does that work? Well, at the moment it doesn’t, but I think it’s safe to assume that it’ll eventually take the font-size
into account when determining the resolved color. Which brings us to APCA.
APCA (Accessible Perceptual Contrast Algorithm) is a new algorithm for measuring color contrast reliably. Andrew Somers, creator of APCA, conducted studies (alongside many other independent studies) and learned that 23% of WCAG 2 “Fails” are actually accessible. In addition, an insane 47% of “Passes” are inaccessible.
Not only should APCA do a better job, but the APCA Readability Criterion (ARC) is far more nuanced, taking into account a much wider spectrum of font sizes and weights (hooray for me, as I’m very partial to 600
as a standard font weight). While the criterion is expectedly complex and unnecessarily confusing, the APCA Contrast Calculator does a decent-enough job of explaining how it all works visually, for now.
contrast-color()
doesn’t use APCA, but the draft spec does allude to offering more algorithms in the future. This wording is odd as it suggests that we’ll be able to choose between the APCA and WCAG algorithms. Then again, we have to remember that the laws of some countries will require WCAG 2 compliance while others require WCAG 3 compliance (when it becomes a standard).
That’s right, we’re a long way off of APCA becoming a part of WCAG 3, let alone contrast-color()
. In fact, it might not even be a part of it initially (or at all), and there are many more hurdles after that, but hopefully this sheds some light on the whole thing. For now, contrast-color()
is using WCAG 2 only.
Using contrast-color()
Here’s a simple example (the same one from earlier) of a darkblue
-colored button with accessibly-colored text chosen by contrast-color()
. I’ve put this darkblue
color into a CSS variable so that we can define it once but reference it as many times as is necessary (which is just twice for now).
button {
--background-color: darkblue;
background-color: var(--background-color);
/* Resolves to white */
color: contrast-color(var(--background-color));
}
And the same thing but with lightblue
:
button {
--background-color: lightblue;
background-color: var(--background-color);
/* Resolves to black */
color: contrast-color(var(--background-color));
}
First of all, we can absolutely switch this up and use contrast-color()
on the background-color
property instead (or in-place of any <color>
, in fact, like on a border):
button {
--color: darkblue;
color: var(--color);
/* Resolves to white */
background-color: contrast-color(var(--color));
}
Any valid <color>
will work (named, HEX, RGB, HSL, HWB, etc.):
button {
/* HSL this time */
--background-color: hsl(0 0% 0%);
background-color: var(--background-color);
/* Resolves to white */
color: contrast-color(var(--background-color));
}
Need to change the base color on the fly (e.g., on hover)? Easy:
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%);
}
}
Similarly, we could use contrast-color()
with the light-dark()
function to ensure accessible color contrast across 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%));
}
}
The interesting thing about APCA is that it accounts for the discrepancies between light mode and dark mode contrast, whereas the current WCAG algorithm often evaluates dark mode contrast inaccurately. This one nuance of many is why we need not only a new color contrast algorithm but also the contrast-color()
CSS function to handle all of these nuances (font size, font weight, etc.) for us.
This doesn’t mean that contrast-color()
has to ensure accessibility at the expense of our “designed” colors, though. Instead, we can use contrast-color()
within the prefers-contrast: more
media query only:
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));
}
}
Personally, I’m not keen on prefers-contrast: more
as a progressive enhancement. Great color contrast benefits everyone, and besides, we can’t be sure that those who need more contrast are actually set up for it. Perhaps they’re using a brand new computer, or they just don’t know how to customize accessibility settings.
Closing thoughts
So, contrast-color()
obviously isn’t useful in its current form as it only resolves to black or white, which might not be accessible. However, if it were improved to resolve to a wider spectrum of colors, that’d be awesome. Even better, if it were to upgrade colors to a certain standard (e.g., WCAG AA) if they don’t already meet it, but let them be if they do. Sort of like a failsafe approach? This means that web browsers would have to take the font size, font weight, element, and so on into account.
To throw another option out there, there’s also the approach that Windows takes for its High Contrast Mode. This mode triggers web browsers to overwrite colors using the forced-colors: active
media query, which we can also use to make further customizations. However, this effect is quite extreme (even though we can opt out of it using the forced-colors-adjust
CSS property and use our own colors instead) and macOS’s version of the feature doesn’t extend to the web.
I think that forced colors is an incredible idea as long as users can set their contrast preferences when they set up their computer or browser (the browser would be more enforceable), and there are a wider range of contrast options. And then if you, as a designer or developer, don’t like the enforced colors, then you have the option to meet accessibility standards so that they don’t get enforced. In my opinion, this approach is the most user-friendly and the most developer-friendly (assuming that you care about accessibility). For complete flexibility, there could be a CSS property for opting out, or something. Just color contrast by default, but you can keep the colors you’ve chosen as long as they’re accessible.
What do you think? Is contrast-color()
the right approach, or should the user agent bear some or all of the responsibility? Or perhaps you’re happy for color contrast to be considered manually?
Exploring the CSS contrast-color() Function… a Second Time originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Recommended Comments