Better Color Gradients with HSLuv

in

Color is tricky! It’s often more complicated than I expect; when I underestimate it, I usually end up taking a dive down into some serious color theory and math. Let’s take a closer look at an interesting challenge when generating color ramps and gradients.


CSS3 introduced the linear-gradient() function that lets you create a smooth transition between multiple colors. It makes it easy to pop a gradient here and there in CSS. For example:

.el { background-image: linear-gradient(90deg, yellow, red); }
A simple gradient from yellow to red.
The resulting gradient rendered by your browser.

A simple gradient from yellow to red. Neat! And it’s all grape!

Except…

The linear-gradient() function uses a simple RGB interpolation to blend between colors. It’s super fast, but it can create less than ideal mix of colors. Especially when the two colors are on the opposite side of the color wheel from each other. Consider:

.el { background-image: linear-gradient(90deg, yellow, blue); }
A simple gradient from yellow to red.

Which looks a little like this:

Yikes, that's a gradient alright!

This might not be what you expect. Depending on what you are doing, you might be asking yourself, What’s up with that weird color in the middle?!

Let’s take a closer look at the math that causes this.

Mixing in Rgb Space

The color mixing algorithm breaks down the start and end color into its red, green and blue color components and mixes them.

First let’s break down the colors:

CSS ColorHexRedGreenBlue
yellow#FFFF002552550
blue#0000FF00255
Color Breakdown

Then, we can define a simple linear tween:

const linearTween = (start, stop) => (i) => (stop-start) * i + start;
A simple linear tween generator

Now, look what happens when we get to the halfway point:

var red = linearTween(255, 0);
var green = linearTween(255, 0);
var blue = linearTween(0, 255);

red(0.5)   | 0;  // 127
green(0.5) | 0;  // 127
blue(0.5)  | 0;  // 127
What happens at 50%?

We get a nice middle gray. Yummy…

So, How Can We Fix It?

While there is no way define a different color interpolation algorithm for linear-gradient(), but we can come with bit of a compromise.

Instead of using linear RGB component tweens, we can use another color space. Then sample the output of this other algorithm, and feed that into our RGB tween and get similar results.

Let’s try HSL. HSL is a cylindrical-coordinate system for defining color. It’s composed of three parts:

Hue is usually represented as degrees; Saturation and Lightness as percentages. Let’s turn our target colors into HSL rather than RGB:

CSS ColorHueSaturationLightness
yellow60°100%50%
blue240°100%50%
Converting our colors to HSL

Now, to interpolate between those two color, we need to make a new kind of tween. We need a circular tween:

var circularTween = (function() {
  var dtor = (d) => d * Math.PI / 180; // degrees => radians
  var rtod = (r) => r * 180 / Math.PI; // radians => degrees

  return (start, stop) => {
    start = dtor(start);
    stop = dtor(stop);
    var delta = Math.atan2(Math.sin(stop - start), Math.cos(stop - start));
    return (i) => (rtod(start + delta * i) + 360) % 360;
  };
})();
A circular tween in degrees. Takes the shortest path between two angles.

Now, let’s tween it up. I’m going to generate seven stops in my tween, and use tinycolor.js to convert from HSL back into hex colors:

var h = circularTween(60, 240);
var s = linearTween(1, 1);
var l = linearTween(0.5, 0.5);

for(var i = 0; i < 7 ; i++) {
    console.log(tinycolor({
        h: h(i/6),
        s: s(i/6),
        l: l(i/6)
    }).toHexString());
}
Tween between yellow and blue, using HSL

And here is what we get out:

> #ffff00
> #ff7f00
> #ff0000
> #ff0080
> #ff00ff
> #7f00ff
> #0000ff
The resulting generated colors.

Now, let’s plug those into a linear-gradient(); this will linearly interpolate between the stops, but will be closer to a true HSL interpolation.

.el {
  background: linear-gradient(
    90deg,
    #ffff00, #ff7f00, #ff0000, #ff0080, #ff00ff, #7f00ff, #0000ff
  );
}
An approximation of a HSL gradient using multiple color stops.
The approximation gradient…

Well, we don’t get that weird gray anymore, but it’s a little … colorful.


But, We Can Do Even Better!

You may be tempted to stop here and move on, after all you can define hsl() colors directly in CSS. But there is still some room for improvement.

HSL doesn’t take into account the perceptual brightness of a color. Greens look brighter than blues, even though they have the same Saturation and Lightness. Other color spaces have attempted to account for human perception in their color model. But they are complicated… But let’s use HSLuv instead!

HSLuv is a self described as …a human-friendly alternative to HSL.

It does a better job of maintaining perceptual brightness between relative hues… Let’s rerun our tween, but instead of HSL we’ll use HSLuv.

var start = hexToHsluv("#FFFF00");
var end = hexToHsluv("#0000FF");

var h = circularTween(start[0], end[0]);
var s = linearTween(start[1], end[1]);
var l = linearTween(start[2], end[2]);

for(var i = 0; i < 7 ; i++) {
    console.log(hsluvToHex(h(i/6), s(i/6), l(i/6)));
}
Tween between yellow and blue, using HSLuv. No cheating this time.

And here is what we get out:

> #ffff00
> #8df100
> #00d48a
> #00b09f
> #008f9b
> #006d97
> #0000ff
The resulting generated colors.

Quick, let’s make a gradient!

.el {
  background: linear-gradient(
    90deg,
    #ffff00, #8df100, #00d48a, #00b09f, #008f9b, #006d97, #0000ff
  );
}
An approximation of a HSLuv gradient using multiple color stops.
Uinsg HSLuv

A little smoother; and doesn’t have a bright peak at cyan! I’m going to call that a win!

Wrapping Up

Remember, a simple linear color gradient might not be the best choice for what you are trying to make.

RGB gradient
HSL gradient
HSLuv gradient
Let's compare: RGB, HSL, HSLuv.