When 255 × 0 does not Equal Zero

First, a caveat: I am not an expert in color management and monitor display profiles. I also only understand the surface-level differences between the default color space of sRGB on the web and other newer models. This conversation will include discussions of both, and I’ll explain what I know and leave room (and links) for others to chime in with better explanations.

Okay… now with that out of the way (and I guess… sorry for the early spoiler of what the article is going to address): Let’s talk the mathematics of blend modes!

How blend modes work on the web today

If you have used Photoshop (or bonus points for fellow Corel-Photo-Paint-in-the-1990s fans) or dealt with mix-blend-mode or background-blend-mode in CSS, you might be somewhat familiar with blend modes. When two layers or elements overlap while a blend mode is specified, a computation will occur at each pixel using the two elements’ RGB color values as inputs, resulting in a new color to display. There are many different modes like multiply and hard-light that each perform a different formula where elements overlap to visually present a different color.

Most of the time, I suspect, designers scroll through different modes to see what looks the best to them, perhaps additionally adding an opacity to the elements to soften the effects. But since every mode has a specific formula, it is entirely possible to understand the resulting color just by looking at your two source colors.

The web follows sRGB for its default color space. This happens for interpolating colors in animations and gradients, and it makes the math fairly straightforward. Even if you specify a value in HSL (with hue, saturation, and lightness), the resulting color will be computed as a RGB color (red, green, and blue). More options are coming in the CSS Colors Level 4 and Level 5 specifications, and there is plenty of talk on the working group Github about how the future might allow us to interpolate along different models.

But as of today, blend mode math happens once for the red channel, once for green, and once for blue.

We will focus on multiply for our example of this math, and then we will will talk about some key issues where the math doesn’t add up.

The multiply blend mode

Let’s take the color red defined as #ff0000. Each channel is a number between 0 and 255 (inclusive). This color has 255 in the red channel and 0 in the green and blue.

#ff0000 is equivalent to the function value of rgb(255,0,0). You can also use percentages in this function, so for the rest of the article we will be talking values of 0% to 100% instead of 0 to 255. With this notation, red is rgb(100%, 0%, 0%).

Let’s say we want to blend this with another color, and let’s choose yellow as rgb(100%,100%,0%)

To perform a multiply blend mode we take the values of each element along each channel and multiply them. We do this in a 0 - 1 scale though (so 100% is 1 and 50% is .5, etc.)

  • Red in RGB: rgb(100%, 0%, 0%)
  • Yellow in RGB: rgb(100%, 100%, 0%)
  • Multiplication in R channel: 1 × 1 = 1
  • Multiplication in G channel: 0 × 1 = 0
  • Multiplication in B channel: 0 × 0 = 0

So our result, when converting back to percentages is rgb(100%, 0%, 0%)… otherwise known as our original red source value!

Multiplying red and yellow results in red. View the live example on CodePen.

What if we instead chose cyan (rgb(0%,100%,100%))?

  • Red in RGB: rgb(100%, 0%, 0%)
  • Cyan in RGB: rgb(0%, 100%, 100%)
  • Multiplication in R channel: 1 × 0 = 0
  • Multiplication in G channel: 0 × 1 = 0
  • Multiplication in B channel: 0 × 1 = 0

Now, converting back to percentages, we get rgb(0%, 0%, 0%) : black

Multiplying red and cyan results in black. View the live example on CodePen.

Since we are multiplying values less than or equal to one, our multiplication blend mode will never result in a color lighter than we started. Each channel will stay the same as one of the sources or be darker (approach zero).

To me, there is magic in values like red and cyan where the result is three zeroes… knowing how to get black from a multiply blend mode gives you a lot of power in controlling how your final design looks. And the math is fairly straightforward, so it’s reassuring in a world of uncertainty that two colors will always result in the same third color any time you blend them together.

Until they don’t.

When math happens outside your color space

So there is a thing with color spaces. Color management is one of those topics I’ve known about for a quarter century but never dug into how it all really works. Everything is a lot simpler when red is red and green is green and you don’t think harder than that. But thankfully several people do think about these topics and as a result our displays have become more vibrant and consistent.

As noted earlier, when you specify a color such as rgb(100% 0% 0%) on the web you are dealing with sRGB color space, and it’s the primary color specification that the web knows. There are other newer ways to specify color, such as through the color() function, that are starting to be implemented, but basically if you want a bright red, you go rgb(100% 0% 0%). When you say you want a gradient from that red to blue (rgb(0% 0% 100%)), the browser determines the in-between values in that same sRGB space and does the math by taking the red channel from 100 to 0 while simultaneously taking the blue channel from 0 to 100.

But different displays and monitors use different color spaces when they render out the color to the user. So, for example, on a Mac, you can open up your System Preferences > Displays > Color and find what display profile your computer uses. Chances are it uses something called Color LCD or iMac and ultimately what that means is when you specify rgb(100% 0% 0%) for red on the web in a browser that uses the device color management scheme (such as Safari or Edge/Chrome) the browser technically uses a different R/G/B value to give you a similar red.

On an iMac I used recently (with the help of the Digital Color Meter tool), the color that is produced at the display level is not 255 0 0 but instead 252 13 27

Red uses slightly different values when in sRGB vs various display profiles.

Similarly my cyan (specified as rgb(0 255 255)) came back as rgb(45 255 254).

Cyan uses slightly different values when in sRGB vs various display profiles.

This isn’t information that most designers or developers need to deal with, since again interpolation on the web happens in sRGB so the math for our gradient earlier still happens between the original values. The display system does its own computation to take those sRGB colors determined by the browser to the appropriate display values.


Now here’s the twist.

Math doesn’t always happen in sRGB.

Specifically… and you might have guessed it by now based on the amount of time I spent discussing blend modes…

Blend mode math in Safari, Edge, and Chrome happens in the display’s color space and not sRGB.

:Head exploding emoji:

So… what does that mean?

It means, as of today, I can only consistently specify colors in sRGB and with blend modes I can only get a result specified by the display color profile. Converting our previously discussed Mac display values for red and cyan into percentages we get:

  • Red: rgb(98.824% 5.098% 10.588%)
  • Cyan: rgb(17.647% 100% 99.608%)

And when Chrome or Safari performs multiplication blend mode with those values (that originally gave us rgb(0 0 0) in sRGB):

  • Multiplication in R channel: .98824 × .17647 = 17.439%
  • Multiplication in G channel: .05098 × 1 = 5.098%
  • Multiplication in B channel: .10588 × .99608 = 10.546%

Which is certainly a dark color… but it is not black. The two source colors defined in sRGB result in a color defined in sRGB using math in a different color space.

Similar combinations of colors that should equal black, such as yellow (rgb(100% 100% 0%)) times blue (rgb(0% 0% 100%)) also get values that are close to black… but not black.

Multiplication in different color spaces produce values that are not quite black. View the live example on CodePen.

Is this expected?

I would argue this is not the expected behavior. Throughout the color/values/animation specifications it discusses how interpolation and math with mixing colors is done in the same default color space as colors are defined (sRGB). In the compositing spec, the only discussion of color spaces is in the “Non-separable blend modes” section, where the blend modes around hue and saturation are discussed.

As mentioned earlier, in Firefox this is not an issue, though people say this is partly because Firefox does not support advanced color management.

Another reason this feels like the incorrect way to handle the math is that it is not consistent with Canvas blending. If you use the same colors in a canvas with the same blending, the result is solid black even in Safari and Chrome.

A red × cyan blend mode within a canvas in Chrome correctly renders solid black. View the live example on CodePen.

But the biggest reason in my head is that if the only way we can really specify colors in one way with a fixed set of numbers… the math should respect those numbers we specify.

The larger discussion

People who have a solid understanding of color management (in addition to colors on the web) are discussing ways to approach this in the future. Chris Lilley and others at the W3C have pushed for color management on the web for a long time, and have discussed options to allow for specifying the color space for a document, etc (thanks to Amelia Bellamy-Royds for pointing me to several related Github issues on the topic). Even in these areas it seems the consensus should be that sRGB is the default for compatibility and especially when you are using that to begin with. In the future when we have the ability to use lab() or color(display-p3 100% 0% 0%) which give us a different range of colors in certain color spaces, we might be able to assume a different default for interpolation and compositing. It will be fun to see where the newer levels of the color specification go with people like Chris, Lea Verou, Una Kravets, and Adam Argyle as editors on the spec.

A big thank you to Eric Portis for walking me through some of the Mac tooling (like the Digital Color Meter) and the basics of the monitor display profiles. That was integral to even start to understand why my visual results were not matching the math defined in the specification. He also started a small discussion on Twitter which verified some of our theories as to what was happening with the blend modes.