I recently discovered a strange issue where inserting colour stops in gradients with my colour picker was not inserting the correct color. It was slightly off, a bit darker than what was expected. I knew Chrome has recently released support for new colour spaces, could this be causing the problem? Turns out, yes. The same issue was not occurring in Firefox.

After digging through various resources on the new colour spaces with CSS gradients, I came to the conclusion that the default interpolation colour spaces for gradients doesn’t appear to sRGB when viewing on a wide-gamut display. The same CSS gradient, without a specified colour space, looked different between Chrome and Firefox.

Firefox 112 (top) vs Chrome 112 (bottom). Subtle difference in the midpoint color where Chrome appears slightly brigher.

It’s a subtle difference, but quite jarring when inserting a color stop at the middle position. Here is the correct behaviour on the left (Firefox) vs Chrome on the right:

How was this happening? Well, the gradient editor on my colour picker uses a little trick with <canvas> when inserting a stop. It draws the current gradient to a <canvas> and pulls the pixel value at the mouse position to insert a stop with the exact colour at that point. This makes sure the gradient appearance does not change when inserting a stop. The gradient visualisation that is displayed behind the stops uses a CSS background gradient, and it turns out that drawing the same gradient to a <canvas> element was interpolating in sRGB by default, while the CSS gradient was interpolating in P3. This meant that the pixel pulled from the canvas was a different color to the corresponding pixel on the CSS background!

The solution I came up with was draw the CSS background using the new “in srgb” directive when supported:

CSS.supports('background: linear-gradient(to right in srgb, #F00, #0F0)');

Or:

@supports (background: linear-gradient(to right in srgb, #F00, #0F0)) {
  div {
    background: linear-gradient(to right in srgb, #F00, #0F0);
  }
}

And then to replace the canvas trick with real mathematical interpolation using the Colorjs.io library.

import Color from "colorjs.io";

// Get color stops either side of the insert position
let color1 = new Color(
   "hsl",
   [stop1.h, stop1.s, stop1.l],
   stop1.a
);
let color2 = new Color(
   "hsl",
   [stop2.h, stop2.s, stop2.l],
   stop2.a
);

// Create a range
let range = color1.range(color2, {
   space: "srgb", // force sRGB interpolation
   outputSpace: "srgb"
});

// pointerXpercent (0-1) is the percent location in the gradient to add the stop
let location = pointerXpercent;

// calculate the percentage position between the stops
let p1 = location * 100 - stop1.xPercent;
let p2 = stop2.xPercent - stop1.xPercent;
let position = p1 / p2;

// The new stop colour!
let newcolor = range(position);

This allowed me to specify the exact colour space regardless of the browser. By providing the color space to the CSS gradient background, and to the colorjs.io range utility, the colors would always match when inserting a new stop.

Another benefit of implementing colorjs.io is it now opens up the future possibility of supporting users to select varying colorspaces in gradients!