Manage Accessible Design System Themes With CSS Color-Contrast()

There’s certainly no shortage of design systems available to use when building your next project. Between IBM’s Carbon, Wanda and Nord, there are plenty of terrific design systems to choose from. Yet, while each one contains its own nuances and opinions, most share a similar goal — simplifying the development process of creating beautifully accessible user interfaces.

It’s an admirable goal and, honestly, one that has led me to shift my own career into design systems. But a core feature at the foundation of many design systems is the extensibility for theming. And why wouldn’t it be? Without some flexibility for branding, every product using a particular system would look the same, à la Bootstrap around 2012.

While providing support for custom themes is vital, it also leaves the most well-intentioned system’s accessibility at the mercy of the implementation. Some teams may spend weeks, if not months, defining their ideal color palette for a rebranding. They’ll labor over each shade and color combination to ensure everything is reliable, informative, and accessible.

Others simply can’t and/or won’t do that.

It’s one thing to require alt text on an img element or a label for an input element, but enforcing accessible color palettes is an entirely different beast. It’s a beast with jagged yellow teeth, fiery-red eyes, and green scales covering its body like sheets of crocodile armor.

At least you think it is. For all you know, it could be a beast of nothing more than indistinct shades of black and slightly darker black.

And therein lies the problem.

The CSS Color-Contrast() Function

Building inclusive products doesn’t mean supporting devices but supporting the people using them.

The CSS color-contrast() function is an experimental feature which is currently a part of Color Module 5. Its purpose — and the reason for the excitement of this article — is to select the greatest contrasting color from a list when compared against a base color.

For the sake of this article, we will refer to the first parameter as the “base color” and the second as the “color list.” These parameters can accept any combination of browser-supported CSS color formats, but be weary of opacities. There’s an optional third parameter, but let’s look at that later. First, let’s define what we mean by this being an experimental feature.

At the time of writing, the color-contrast() feature is only available in the Safari Technology Preview browser. The feature can be toggled through the Develop and Experimental Features menus. The following demos will only work if the feature is enabled in that browser. So, if you’d like to switch, now wouldn’t be the worst time to do so.

Now, with the base syntax, terminology, and support out of the way, let’s dive in. ?

Color Me Intrigued

It was Rachel Andrew’s talk at AxeCon 2022, “New CSS With Accessibility in Mind”, where I was introduced to color-contrast(). I scribbled the function down into my notebook and circled it multiple times to make it pop. Because my mind has been entirely in the world of design systems as of late, I wondered how big of an impact this little CSS feature could have in that context.

In her presentation, Rachel demoed the new feature by dynamically defining text colors based on a background. So, let’s start there as well, by setting background and text colors on an article.

article {
  --article-bg: #222;

  background: var(--article-bg);
  color: color-contrast(var(--article-bg) vs #FFF, #000);

We start by defining the --article-bg custom property as a dark grey, #222. That property is then used as the base color in the color-contrast() function and compared against each item in the color list to find the highest contrasting value.

Base Color Color List Contrast Ratio
#222 15.9
#222 #000 1.31

As a result, the article’s color will be set to white, #FFF.

But this can be taken further.

We can effectively chain color-contrast() functions by using the result of one as the base color of another. Let’s extend the article example by defining the ::selection color relative to its text.

article {
  --article-bg: #222;
  --article-color: color-contrast(var(--article-bg) vs #FFF, #000);

  background: var(--article-bg);
  color: var(--article-color);

  ::selection {
    background: color-contrast(var(--article-color) vs #FFF, #000);

Now, as the text color is defined, so will its selection background.

The optional third parameter for color-contrast() defines a target contrast ratio. The parameter accepts either a keyword — AA, AA-large, AAA, and AAA-large — or a number. When a target contrast is defined, the first color from the color list that meets or exceeds it is selected.

This is where color-contrast() could really empower design systems to enforce a specific level of accessibility.

Let’s break this down.

.dark-mode {
  --bg: #000;
  --color-list: #111, #222;

.dark-mode {
  background: var(--bg);
  color: color-contrast(var(--bg) vs var(--color-list));

  &.with-target {
    color: color-contrast(var(--bg) vs var(--color-list) to AA);

The magic here happens when the two color declarations are compared.

The base .dark-mode class does not use a target contrast. This results in the color being defined as #222, the highest contrasting value from the color list relative to its base color of black. Needless to say, the contrast ratio of 1.35 may be the highest, but it’s far from accessible.

Compare this to when the .dark-mode and .with-target classes are combined, and a target contrast is specified. Despite using the same base color and color list, the result is much different. When no value in the color list meets the AA (4.5) target contrast, the function selects a value that does. In this case, white.

This is where the potential of color-contrast() is the brightest.

In the context of design systems, this would allow a system to enforce a level of color accessibility with very granular control. That level could also be a :root-scoped custom property allowing the target contrast to be dynamic yet global. There’s a real feeling of control on the product side, but that comes at a cost during the implementation.

There’s a logical disconnect between the code and the result. The code doesn’t communicate that the color white will be the result. And, of course, that control on the product side translates to uncertainty with the implementation. If a person is using a design system and passes specific colors into their theme, why are black and white being used instead?

The first concern could be remedied by understanding the color-contrast() feature more deeply, and the second could be alleviated by clear, communicative documentation. However, in both cases, this shifts the burden of expectation onto the implementation side, which is not ideal.

In some cases, the explicit control will justify the costs. However, there are other drawbacks to color-contrast() that will need to be considered in all cases.

Not All That Glitters Is Gold

There are inevitable drawbacks to consider, as with any experimental or new feature, and color-contrast() is no different.

Color And Visual Contrasts Are Different Things

When using color-contrast() to determine text color based on its background, the function is comparing exactly that — the colors. What color-contrast() does not take into consideration are other styles that may affect visual contrast, such as font size, weight, and opacity.

This means it’s possible to have a color pairing that technically meets a specific contrast threshold but still results in an inaccessible text because its size is too small, weight is too light, or its opacity is too transparent.

To learn more about accessible typography, I highly recommend Carie Fisher’s talk, “Accessible Typography Essentials.”

Custom Properties And Fallbacks

Since CSS custom properties support fallback values for when the property is not defined, it seemed like a good approach to use color-contrast() as a progressive enhancement.

--article-color: color-contrast(#000 vs #333, #FFF);
color: var(--article-color, var(--fallback-color));

If color-contrast() is not supported, the --article-color property would not be defined, and therefore the --fallback-color would be used. Unfortunately, that’s not how this works.

An interesting thing happens in unsupported browsers — the custom property would be defined with the function itself. Here’s an example of this from Chrome DevTools:

Because the --article-color property is technically defined, the fallback won’t trigger.

However, that’s not to say color-contrast() can’t be used progressively, though. It can be paired with the @supports() function, but be mindful if you decide to do so. As exciting as it may be, with such limited support and potential for syntax and/or functionality changes, it may be best to hold off on sprinkling this little gem throughout an entire codebase.

@supports (color: color-contrast(#000 vs #fff, #eee)) {
  --article-color: color-contrast(var(--article-color) vs #fff, #000);

The Highest Contrast Doesn’t Mean Accessible Contrast

Despite the control color-contrast() can offer with colors and themes, there are still limitations. When the function compares the base color against the list and no target contrast is specified, it will select the highest contrasting value. Just because the two colors offer the greatest contrast ratio, it doesn’t mean it’s an accessible one.

h1 {
  background: #000;
  color: color-contrast(#000 vs #111, #222);

In this example, the background color of black. #000 is compared against two shades of dark grey. While #222 would be selected for having the “greatest” contrast ratio, pairing it with black would be anything but great.

No Gradient Support

In hindsight, it was maybe a touch ambitious trying gradients with color-contrast(). Nevertheless, through some testing, it seems gradients are not supported. Which, once I thought about it, makes sense.

If a gradient transitioned from black to white, what would the base color be? And wouldn’t it need to be relative to the position of the content? It’s not like the function can interpret the UI. However, Michelle Barker has experimented with together to support this exact use case.

It’s not you, color-contrast(), it’s me. Well, it’s actually the gradients, but you know what I mean.

Wrapping Up

That was a lot of code and demos, so let’s take a step back and review color-contrast().

The function compares a base color against a color list, then selects the highest contrasting value. Additionally, it can compare those values against a target contrast ratio and either select the first color to meet that threshold or use a dynamic color that does. Pair this with progressive enhancement, and we’ve got a feature that can drastically improve web accessibility.

I believe there are still plenty of unexplored areas and use cases for color-contrast(), so I want to end this article with some additional thoughts and/or questions.

How do you see this feature being leveraged when working with different color modes, like light, dark, and high contrast? Could a React-based design system expose an optional targetContrast prop on its ThemeProvider in order to enforce accessibility if the theme falls short? Would there be a use case for the function to return the lowest contrasting value instead? If there were two base colors, could the function be used to find the best contrasting value between them?

What do you think?


  • “ for the First Time”, Chris Coyier
  • on MDN
  • on W3 Color Module Level 5

Further Reading on Smashing Magazine

This content was originally published here.