Generating text colors with CSS, and balancing compliance vs readability
Can we emulate the upcoming CSS contrast-color()
function via CSS features that have already widely shipped?
And if so, what are the tradeoffs involved and how to best balance them?
Relative Colors
Out of all the CSS features I have designed, Relative Colors aka Relative Color Syntax (RCS) is definitely among the ones I’m most proud of. In a nutshell, they allow CSS authors to derive a new color from an existing color value by doing arbitrary math on color components in any supported color space:
--color-lighter: oklch(from var(--color) calc(l * 1.2) c h);
--color-alpha-50: oklab(from var(--color) l a b / 50%);
The elevator pitch was that by allowing lower level operations they provide authors flexibility on how to derive color variations, giving us more time to figure out what the appropriate higher level primitives should be.
As of May 2024, RCS has shipped in every browser except Firefox. but given that it is an Interop 2024 focus area, that Firefox has expressed a positive standards position, and that the Bugzilla issue has had some recent activity and has been assigned, I am optimistic it would ship in Firefox soon. My guess it that it would become Baseline by the end of 2024.
Even if my prediction is off, it already is available to 83% of users worldwide,
and if you sort its caniuse page by usage,
you will see the vast majority of the remaining 17% doesn’t come from Firefox,
but from older Chrome and Safari versions.
I think its current market share warrants production use today, as long as we use @supports
to make sure things work in non-supporting browsers, even if less pretty.
Most Relative Colors tutorials revolve around its primary driving use cases: making tints and shades or other color variations by tweaking a specific color component up or down, and/or overriding a color component with a fixed value, like the example above. While this does address some very common pain points, it is merely scratching the surface of what RCS makes possible. This article explores a more advanced use case, with the hope that it will spark more creative uses of RCS in the wild.
The CSS contrast-color()
function
A big longstanding CSS pain point is that there is no way to specify a text color that is automatically guaranteed to be readable over an arbitrary background color, e.g. white on darker backgrounds and black on lighter backgrounds.
Why would one need that? The primary use case is when colors are outside the control over the CSS author. This includes:
- User-defined colors. An example you’re likely familiar with: GitHub labels. Think of how you select an arbitrary color when creating a label and GitHub automatically picks the text color — often poorly (we’ll see why in a bit)
- Colors defined by another developer. E.g. you’re writing a web component that supports certain CSS variables for styling. You could require separate variables for the text and background, but that reduces the usability of your web component by making it more of a hassle to use. Wouldn’t it be great if it could just use a sensible default, that you can, but rarely need to override?
- Colors defined by an external design system, like Open Props or Material Design.
Even in a codebase where a single author controls everything, reducing couplings can improve modularity and facilitate better code reuse.
The good news is that this is actually coming, as the CSS function contrast-color()
.
This is not new, you may have heard of it as color-contrast()
before, an earlier name.
I recently drove consensus to scope it down to an MVP that addresses the most prominent pain points and can actually ship soonish,
as it circumvents some very difficult design decisions that had caused the full-blown feature to stall.
Usage will look like this:
background: var(--color);
color: contrast-color(var(--color));
Glorious, isn’t it? Of course, soonish in spec years is still, well, years. As a data point, you can see in my past spec work that with a bit of luck (and browser interest), it can take as little as 2 years to get a feature shipped across all major browsers after it’s been specced. But 2 years is also a long time (and it could be longer). Is there any recourse until then?
As you may have guessed from the title, the answer is yes.
It may not be pretty, but there is a way to emulate contrast-color()
(or something close to it) using Relative Colors.
Using RCS to automatically compute a contrasting text color
In the following we will use the OKLCh color space, which is the most perceptually uniform polar color space that CSS supports.
Let’s assume there is a Lightness value above which black text is guaranteed to be readable regardless of the chroma and hue,
and below which white text is guaranteed to be readable.
We will validate that assumption later, but for now let’s take it for granted.
In the rest of this article, we’ll call that value the threshold.
For now we will use 0.7
, but will compute it more rigorously in the next section.
Let’s assign it to a variable:
--l-threshold: 0.7;
Most RCS examples in the wild use calc()
with simple additions and multiplications.
However, any math function supported by CSS is actually fair game, including clamp()
, trigonometric functions, and many others.
For example, if you wanted to create a lighter tint of a core color with RCS, you could do something like this:
background: oklch(from var(--color) 90% clamp(0, c, 0.1) h);
Let’s work bakwards from the desired result.
We want to come up with an expression that is composed of CSS math functions already supported widely
and will return 1
if l
≤ var(--l-threshold)
and 0
if otherwise.
Then we could use that value as the lightness of a new color:
--l: /* ??? */;
color: oklch(var(--l) 0 0);
We can simplify the task a bit:
We don’t need to find an expression that returns an exact value.
If we can manage to find an expression that will be negative when L > Lthreshold and > 1 when L ≤ Lthreshold,
we can use clamp(0, /* expression */, 1)
to get the desired result.
One idea would be to use ratios.
The ratio of
Putting it all together, it looks like this:
--l-threshold: 0.7;
--l: clamp(0, (var(--l-threshold) / l - 1) * infinity, 1);
color: oklch(from var(--color) var(--l) 0 h);
One worry might be that if L gets close enough to the threshold we could get a number between 0 - 1, but in my experiments this never happened, presumably since precision is finite.
Fallback for browsers that don’t support RCS
The last piece of the puzzle is to provide a fallback for browsers that don’t support RCS.
We can use @supports
with any color property and any relative color value, e.g.:
.contrast-color {
--l: clamp(0, (var(--l-threshold) / l - 1) * infinity, 1);
/* Fallback */
background: hsl(0 0 0 / 50%);
color: white;
}
@supports (color: oklch(from red l c h)) {
.contrast-color {
color: oklch(from var(–color) var(–l) 0 h);
background: none;
}
}
In the spirit of making sure things work in non-supporting browsers, even if less pretty, some fallback ideas could be:
- A white or semi-transparent white background with black text or vice versa.
-webkit-text-stroke
with a color opposite to the text color. This works better with bolder text, since half of the outline is inside the letterforms.- Many
text-shadow
values with a color opposite to the text color. This works better with thinner text, as it’s drawn behind the text.
Does this mythical L threshold actually exist?
In the previous section we’ve made a pretty big assumption: That there is a Lightness value (Lthreshold) above which black text is guaranteed to be readable regardless of the chroma and hue, and below which white text is guaranteed to be readable regardless of the chroma and hue. But does such a value exist? It is time to put this claim to the test.
When people first hear about perceptually uniform color spaces like Lab, LCH or their improved versions, OkLab and OKLCH, they imagine that they can infer the contrast between two colors by simply comparing their L(ightness) values. This is unfortunately not true, as contrast depends on more factors than perceptual lightness. However, there is certainly significant correlation between Lightness values and contrast.
At this point, I should point out that while most web designers are aware of the WCAG 2.1 contrast algorithm, which is part of the Web Content Accessibility Guidelines and baked into law in many countries, it has been known for years that it produces extremely poor results. So bad in fact that in some tests it performs almost as bad as random chance for any color that is not very light or very dark. There is a newer contrast algorithm, APCA that produces far better results, but is not yet part of any standard or legislation, and there have previously been some bumps along the way with making it freely available to the public (which seem to be largely resolved).
So where does that leave web authors? In quite a predicament as it turns out. It seems that the best way to create accessible color pairings right now is a two step process:
- Use APCA to ensure actual readability
- Compliance failsafe: Ensure the result does not actively fail WCAG 2.1.
I ran some quick experiments using Color.js where I iterate over the OKLCh reference range (loosely based on the P3 gamut) in increments of increasing granularity and calculate the lightness ranges for colors where white was the “best” text color (= produced higher contrast than black) and vice versa. I also compute the brackets for each level (fail, AA, AAA, AAA+) for both APCA and WCAG.
I then turned my exploration into an interactive playground where you can run the same experiments yourself, potentially with narrower ranges that fit your use case or higher granularity.
This is the table produced with C ∈ [0, 0.4] (step = 0.025) and H ∈ [0, 360) (step = 1):
Text color | Level | APCA | WCAG 2.1 | ||
---|---|---|---|---|---|
Min | Max | Min | Max | ||
white | best | 0% | 75.2% | 0% | 61.8% |
fail | 71.6% | 100% | 62.4% | 100% | |
AA | 62.7% | 80.8% | 52.3% | 72.1% | |
AAA | 52.6% | 71.7% | 42% | 62.3% | |
AAA+ | 0% | 60.8% | 0% | 52.7% | |
black | best | 66.1% | 100% | 52% | 100% |
fail | 0% | 68.7% | 0% | 52.7% | |
AA | 60% | 78.7% | 42% | 61.5% | |
AAA | 69.4% | 87.7% | 51.4% | 72.1% | |
AAA+ | 78.2% | 100% | 62.4% | 100% |
Note that these are the min and max L values for each level. E.g. the fact that white text can fail WCAG when L ∈ [62.4%, 100%] doesn’t mean that every color with L > 62.4% will fail WCAG, just that some do. So, we can only draw meaningful conclusions by inverting the logic: Since all white text failures are have an L ∈ [62.4%, 100%], it logically follows that if L < 62.4%, white text will pass WCAG regardless of what the color is.
By applying this logic to all ranges, we can draw similar guarantees for many of these brackets:
0% to 52.7% | 52.7% to 62.4% | 62.4% to 66.1% | 66.1% to 68.7% | 68.7% to 71.6% | 71.6% to 75.2% | 75.2% to 100% | ||
---|---|---|---|---|---|---|---|---|
Compliance WCAG 2.1 | white | ✅ AA | ✅ AA | ❌ | ❌ | ❌ | ❌ | ❌ |
black | ❌ | ✅ AA | ✅ AAA | ✅ AAA | ✅ AAA | ✅ AAA | ✅ AAA+ | |
Readability APCA | white | 😍 Best | 😍 Best | 😍 Best | 🙂 OK | 🙂 OK | ❌ | ❌ |
black | ❌ | ❌ | ❌ | ❌ | 🙂 OK | 🙂 OK | 😍 Best |
You may have noticed that in general, WCAG has a lot of false negatives around white text, and tends to place the Lightness threshold much lower than APCA. This is a known issue with the WCAG algorithm.
Therefore, to best balance readability and compliance, we should use the highest threshold we can get away with. This means:
- If passing WCAG is a requirement, the highest threshold we can use is 62.3%.
- If actual readability is our only concern, we can safely ignore WCAG and pick a threshold somewhere between 68.7% and 71.6%, e.g. 70%.
Here’s a demo so you can see how they both play out. Edit the color below to see how the two thresholds work in practice, and compare with the actual contrast brackets, shown on the table on the left.
Your browser does not support Relative Color Syntax, so the demo below will not work. This is what it looks like in a supporting browser:
Text color | APCA | WCAG 2.1 |
---|---|---|
White | ||
Black |
Avoid colors marked “P3+”, “PP” or “PP+”, as these are almost certainly outside your screen gamut, and browsers currently do not gamut map properly, so the visual result will be off.
Note that if your actual color is more constrained (e.g. a subset of hues or chromas), you might be able to balance these tradeoffs better by using a different threshold. Run the experiment yourself with your actual range of colors and find out!
Here are some examples of narrower ranges I have tried and the highest threshold that still passes WCAG 2.1:
Description | Color range | Threshold |
---|---|---|
Neutrals | C ∈ [0, 0.03] | 67% |
Muted colors | C ∈ [0, 0.1] | 65.6% |
Warm colors (reds/oranges/yellows) | H ∈ [0, 100] | 66.8% |
Pinks/Purples | H ∈ [300, 370] | 67% |
You can even turn this into a utility class that you can combine with different thesholds:
.contrast-color {
--l: clamp(0, (var(--l-threshold, 0.623) / l - 1) * infinity, 1);
/* Fallback for browsers that don’t support RCS */
color: white;
text-shadow: 0 0 .05em black, 0 0 .05em black, 0 0 .05em black, 0 0 .05em black;
}
@supports (color: oklch(from red l c h)) {
.contrast-color {
color: oklch(from var(–color) var(–l) 0 h);
text-shadow: none;
}
}
.pink {
–l-threshold: 0.67;
}
Future work
This is only a start. I can imagine many directions for improvement:
- Since RCS allows us to do math with any of the color components
in any component, I wonder if there is a better formula that takes
c
andh
into account. - Currently we only calcualte thresholds for white and black text. However, in real designs, we rarely want pure black text. How would this extend to darker tints of the background color?