I think many of us who work with CSS and accessibility have been looking for a solution that helps provide the right amount of contrast for a given color. It’s floated around as a function within different CSS color specifications, currently found as contrast-color()
in CSS Color Module Level 5 with the following syntax:
background: var(--color);
color: contrast-color(var(--color));
Unfortunately, this isn’t available in any browser and there’s no references found for it in MDN or caniuse. So, do we just keep waiting? Maybe not!
Prior art
Over 5 years ago, there was a post on CSS Tricks that began to poke at this problem. Josh Bader outlined a method that takes the channels of an RGB color and combines the values into a luminance value to be reassigned into a new RGB color.
:root {
--red: 28;
--green: 150;
--blue: 130;
--accessible-channel: calc(
(
(
(
(var(--red) * 299) +
(var(--green) * 587) +
(var(--blue) * 114)
) / 1000
) - 128
) * -1000
);
--accessible-color: rgb(
var(--accessible-channel)
var(--accessible-channel)
var(--accessible-channel)
);
}
The problem with this approach is that you needed to know the amount of each channel such that it could be combined together. This made it tedious to define a color because it’s uncommon to have to set color channels individually, especially when we begin to use variables and tokens which are curated by folks that aren’t close to the code.
Luckily, we do have some modern CSS that can help with this and get us much closer to a good developer experience with only CSS.
Relative color syntax
The new CSS Relative Color Syntax part of baseline 2024 allows you to get the individual channels of a color function and mutate the output. Here’s a simple example:
background: rgb(from var(--color) r r r);
We can identify relative color with the from
keyword in the color function. What this would do is take the red channel of the var(--color)
and assign that value to the R, G, and B channels of a new color. In other words, using the red channel of the given color to make a new grayscale color. You should see that this is the missing piece to Josh’s idea, so here’s how we’re going to put it together.
First, we’re going to simplify Josh’s implementation a bit. Here’s the calculation we’re going to use instead:
clamp(0, (((r * .299) + (g * .587) + (b * .114)) - 128) * -1000, 255);
Doing it this way omits the / 1000
by using decimals for the magic luminance numbers. This will return the amount for each RGB channel to be either black or white. This means we’d need to duplicate the same calculation a few times in the relative color function.
rgb(
from var(--color)
clamp(0, (((r * .299) + (g * .587) + (b * .114)) - 128) * -1000, 255)
clamp(0, (((r * .299) + (g * .587) + (b * .114)) - 128) * -1000, 255)
clamp(0, (((r * .299) + (g * .587) + (b * .114)) - 128) * -1000, 255)
);
We need to duplicate this because the relative color syntax must return a color, not an individual number, so we need to do the same calculation at each channel. It’s sort of like doing the procedure all in a single function but as a requirement due to how relative colors work. This makes the code a little messy, so instead I’ll recommend registering a CSS custom property that can be reused:
@property --channel {
syntax: "*";
inherits: false;
initial-value: clamp(
0,
(((r * .299) + (g * .587) + (b * .114)) - 128) * -1000,
255
);
}
Now that we have that, we can finally use the --channel
variable within the relative color syntax to create the new color:
.color {
--color: #bada55;
background: var(--color);
color: rgb(
from var(--color)
var(--channel)
var(--channel)
var(--channel)
);
}
You could also opt to hide the relative syntax in another variable so your peers don’t need to remember the syntax and can use a custom property instead:
/* tokens */
:root {
--color: #bada55;
--contrast-color: rgb(
from var(--color)
var(--channel)
var(--channel)
var(--channel)
);
}
/* app */
.color {
background: var(--color);
color: var(--contrast-color);
}
Check out the codepen below which includes a color picker and notice how the text changes color from black to white depending on your chosen color:
EDIT (2025-06-04): After sharing the approach Matt Ström-Awn made some improvements. Here’s a quote from the thread in the Design System’s Slack:
super cool! you sparked my curiosity; i know that the wcag contrast algorithm uses xyz’s ‘y’ value as its definition of relative luminosity; so if you do the relative color in xyz instead of rgb it simplifies the math a bit.
color: color(from var(--color) xyz round(up, min(1, max(0, 0.18 - y))) round(up, min(1, max(0, 0.18 - y))) round(up, min(1, max(0, 0.18 - y))) );
Admittedly, I’m not good with color math but I know Matt is so I trust this is a better approach.