4 posts on A11y

Generating text colors with CSS, and balancing compliance vs readability

15 min read 0 comments 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?

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.

Screenshot from GitHub issues showing many different labels with different colors

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 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 lvar(--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 LLthreshold, we can use clamp(0, /* expression */, 1) to get the desired result.

One idea would be to use ratios.

The ratio of LLthreshold is > 1 for LLthreshold and < 1 when L > Lthreshold. This means that LLthreshold1 will be a negative number for L > Lthreshold and a positive one for L > Lthreshold. Then all we need to do is multiply that expression by a huge number so that the positive number is guaranteed to be over 1.

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).

Some text
Some text
Which of the two seems more readable? You may be surprised to find that the white text version fails WCAG 2.1, while the black text version even passes WCAG AAA!

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.

Calculating lightness ranges and contrast brackets for black and white on different background colors.

This is the table produced with C ∈ [0, 0.4] (step = 0.025) and H ∈ [0, 360) (step = 1):

Text colorLevelAPCAWCAG 2.1
MinMaxMinMax
whitebest0%75.2%0%61.8%
fail71.6%100%62.4%100%
AA62.7%80.8%52.3%72.1%
AAA52.6%71.7%42%62.3%
AAA+0%60.8%0%52.7%
blackbest66.1%100%52%100%
fail0%68.7%0%52.7%
AA60%78.7%42%61.5%
AAA69.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.1white✅ AA✅ AA
black✅ AA✅ AAA✅ AAA✅ AAA✅ AAA✅ AAA+
Readability APCAwhite😍 Best😍 Best😍 Best🙂 OK🙂 OK
black🙂 OK🙂 OK😍 Best
Contrast guarantees we can infer for black and white text over arbitrary colors. OK = passes but is not necessarily best, ❌ = cannot infer any guarantees.

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: Screenshot of demo

Threshold = 70%
Threshold = 62.3%
Actual contrast ratios
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 and h 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?

Contrast Ratio has a new home — and this is great news!

1 min read 0 comments Report broken page

It has been over a decade when I launched contrast-ratio.com, an app to calculate the WCAG 2.1 contrast ratio between any two CSS colors. At the time, all similar tools suffered from several flaws when being used for CSS editing:

  • No support for semi-transparent colors (Since WCAG included no guidance for alpha transparency — I had to do original research to calculate the contrast ratio range for that case)

  • No support for color formats other than hex or (at best) RGB with sliders. I wanted something where I could just paste a CSS color just like I had it specified in my code (e.g. hsl(220 10% 90%), possibly tweak it a bit to pass, then paste it back. I didn’t want to use unintuitive hex colors, and I didn’t want to fiddle with sliders.

  • Poor UX, often calculating the actual ratio required further user actions, making iteration tedious

Over the years, contrast-ratio.com grew in popularity: it was recommended in several books, talks, and workshops. It basically became the standard URL developers would visit for this purpose.

However, I’ve been too busy to work on it further beyond just merging pull requests. My time is currently split between the dozens of open source projects I have started and maintain, my TAG work, my CSS WG work, and my teaching & research at MIT.

Therefore, when Ross and Drew from Siege Media approached me with a generous offer to buy the domain, and a commitment to take over maintainship of the open source project, I was cautiously optimistic. But now, after having seen some of their plans for it, I could not be more certain that the future of this tool is much brighter with them.

Please join me in welcoming them to the project and help them get settled in as new stewards!

ETA: Siege Media Press Release


What is the best way to mark up an exclusive button group?

2 min read 0 comments Report broken page

A few days ago I asked Twitter a seemingly simple question (I meant aria-pressed, not aria-selected but Twitter doesn’t allow edits…):

For background, I was implementing a web component for an app I’m working on at work and I was getting into some pretty weird rabbit holes with my approach of generating radios and labels.

Unsurprisingly, most people thought the best solution is radio buttons and labels. After all, it works without CSS, right? Progressive enhancement and everything?

That’s what I thought too. I had contorted my component to generate labels and radios in the Shadow DOM from buttons in the light DOM, which resulted in awkward code and awkward CSS, but I felt I was fighting the good fight and doing the best thing for accessibility.

All this was challenged when the actual accessibility expert, Léonie Watson chimed in. For those of you who don’t know her, she is pretty much the expert when it comes to web accessibility and standards. She is also visually impaired herself, giving her a firsthand experience many other a11y aficionados lack. Her recommendation was contrary to what most others were saying:

Continue reading


Easy color contrast ratios

2 min read 0 comments Report broken page

I was always interested in accessibility, but I never had to comply with any guidelines before. At W3C, accessibility is considered very important, so everything we make needs to pass WCAG 2.0 AA level. Therefore, I found myself calculating color contrast ratios very frequently. It was a very enlightening experience. I used to think that WCAG-mandated contrast ratios were too restrictive and basically tried to force you to use black and white, a sentiment shared by many designers I’ve spoken to. Surprisingly, in practice, I found that in most cases they are very reasonable: When a color combination doesn’t pass WCAG, it usually *is* hard to read. After all, the possible range for a contrast ratio is 1-21 but only ratios lower than 3 don’t pass WCAG AA (4.5 if you have smaller, non-bold text). So, effectively 90% of combinations will pass (82.5% for smaller, non-bold text).

There are plenty of tools out there for this. However, I found that my workflow for checking a contrast ratio with them was far from ideal. I had to convert my CSS colors to hex notation (which I don’t often use myself anymore), check the contrast ratio, then adjust the colors as necessary, covert again etc. In addition, I had to adjust the lightness of the colors with a blindfold, without being able to see the difference my adjustments would make to the contrast ratio. When using semi-transparent colors, it was even worse: Since WCAG only describes an algorithm for opaque colors, all contrast tools only expect that. So, I had to calculate the resulting opaque colors after alpha blending had taken place. After doing that for a few days, I got so fed up that I decided to make my own tool.

In addition, I discovered that there was no documented way of calculating the contrast ratio range that can be produced with a semi-transparent background, so I came up with an algorithm (after many successive failures to find the range intuitively), published it in the w3c-wai-ig mailing list and used the algorithm in my app, effectively making it the first one that can accept semi-transparent colors. If your math is less rusty than mine, I’d appreciate any feedback on my reasoning there.

Below is a list of features that make this tool unique for calculating color contrast ratios:

  • Accepts any CSS color the browser does, not just hex colors. To do this, it defers parsing of the color to the browser, and queries the computed style, which is always rgb() or rgba() with 0-255 ranges which be parsed much more easily than the multitude of different formats than modern browsers accept (and the even more that are coming in the future).
  • Updates as you type, when what you’ve typed can be parsed as a valid CSS color.
  • Accepts semi transparent colors. For semi-transparent backgrounds, the contrast ratio is presented with an error margin, since it can vary depending on the backdrop. In that case, the result circle will not have a solid background, but a visualization of the different possible results and their likelihood (see screenshot).
  • You can share your results by sharing the URL. The URL hashes have a reasonable structure of the form #foreground-on-background, e.g. #black-on-yellow so you can even adjust the URL as a form of input.
  • You can adjust the color by incrementing or decrementing its components with the keyboard arrow keys until you get the contrast right. This is achieved by including my Incrementable library.

Browser support is IE10 and modern versions of Firefox, Safari, Chrome, Opera. Basic support for IE9. No responsive version yet, sorry (but you can always send pull requests!)

Save the link: contrast-ratio.com

Edit 2022: Link updated to reflect current one. Original link was leaverou.github.com/contrast-ratio