On this page Something not working? Report broken page
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?
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: hsl(from var(--color) h s calc(l * 1.2)); --color-lighterer: oklch(from var(--color) calc(l + 0.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 enables. This article explores a more advanced use case, with the hope that it will spark more creative uses of RCS in the wild.
One of the big longstanding CSS pain points is that it’s impossible to automatically specify a text color that is guaranteed to be readable on arbitrary backgrounds, e.g. white on darker colors and black on lighter ones. Why would one need that? The primary use case is when colors are outside the CSS author’s control. 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.
- Colors defined by an external design system, like Open Props, Material Design, or even (gasp) Tailwind. GitHub Labels are an example where colors are user-defined, and the UI needs to pick a text color that works with them. GitHub uses WCAG 2.1 to determine the text color, which is why (as we will see in the next section) the results are often poor.
Even in a codebase where every line of CSS code is controlled by a single author, reducing couplings can improve modularity and facilitate code reuse. The good news is that this is not going to be a pain point for much longer. The CSS function contrast-color() was designed to address exactly that. 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. I then added it to the spec per WG resolution, though some details still need to be ironed out. 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. When the standards work is also well-funded, there have even been cases where a feature went from conception to baseline in 2 years, with Cascade Layers being the poster child for this: proposal by Miriam in Oct 2019, shipped in every major browser by Mar 2022. But 2 years is still a long time (and there are no guarantees it won’t be longer). What is our 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. In the following we will use the OKLCh color space, which is the most perceptually uniform polar color space that CSS supports.
Perceptually uniform color space: A color space where the Euclidean distance between two colors is proportional to their perceptual difference. RGB spaces (and their polar forms, HSL, HSV, HSB, HWB, etc.) are typically not perceptually uniform. Some examples of what this means for HSL in my older post on LCH. Examples of perceptually uniform color spaces include Lab, LCH, OkLab, and OkLCh.
Polar color space: A color space where colors are represented as an angular hue (which determines the “core” color, e.g. red, yellow, green, blue, etc.) and two components that control the exact shade of that hue (typically some version of colorfulness and lightness).
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 and represent it as Lthreshold. We will compute this value more rigously in the next section (and prove that it actually exists!), but for now let’s use 0.7 (70%). We can assign it to a variable to make it easier to tweak:
--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 backwards from the desired result. We want to come up with an expression that is composed of widely supported CSS math functions, and will return 1 if L ≤ Lthreshold and 0 otherwise. If we could write such an expression, we could then use that value as the lightness of a new color:
--l: /* ??? */; color: oklch(var(--l) 0 0);
The CSS math functions that are widely supported are:
- calc()
- min(), max(), clamp()
- Trigonometric functions (sin(), cos(), tan(), asin(), acos(), atan(), atan2()) (another CSS feature I proposed, back in 2018 😁)
- Exponential functions (exp(), log(), log2(), log10(), sqrt())
How could we simplify the task? One way is to relax what our expression needs to return. We don’t actually need an exact 0 or 1 If we can manage to find an expression that will give us 0 when L > Lthreshold and > 1 when L ≤ Lthreshold, we can just use clamp(0, /* expression */, 1) to get the desired result. One idea would be to use ratios, as they have this nice property where they are > 1 if the numerator is larger than the denominator and ≤ 1 otherwise. The ratio of LLthreshold is > 1 for L ≤ Lthreshold and < 1 when L > Lthreshold…