refactorman

Better Color Gradients with HUSL

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.


While it has been discussed by others, it’s a topic that’s worthy of revisiting in the context of CSS. CSS3 introduced the linear-gradient() function that lets you create a smooth fade between multiple colors. It makes it easy to pop a gradient here and there in CSS. For example:

A simple gradient from yellow to red.
1
.el { background-image: linear-gradient(90deg, yellow, 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:

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

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

Color Breakdown
CSS Color Hex Red Green Blue
yellow #FFFF00 255 255 0
blue #0000FF 0 0 255

Then, we can define a simple linear tween:

A simple linear tween generator
1
2
3
function linearTween(start, stop) {
return function tween(i) { return (stop-start) * i + start; };
};

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

What happens at 50%?
1
2
3
4
5
6
7
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

We get a nice middle gray. Yummy…

So, how can we fix it?

Well, there is no way define a different color tween 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:

Converting our colors to HSL.
CSS Color Hue Saturation Lightness
yellow 60 100% 50%
blue 240 100% 50%

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

A circular tween in degrees. Takes the shortest path between two angles.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var circularTween = (function() {
// degrees => radians
var dtor = function(d) { return d * Math.PI / 180; };
// radians => degrees
var rtod = function(r) { return r * 180 / Math.PI; };

return function(start, stop) {
start = dtor(start);
stop = dtor(stop);
var delta = Math.atan2(Math.sin(stop - start), Math.cos(stop - start));
return function tween(i) {
return (rtod(start + delta * i) + 360) % 360;
};
};
})();

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:

Tween between yellow and blue, using HSL
1
2
3
4
5
6
7
8
9
10
11
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());
}

And here is what we get out:

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

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.

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

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 HUSL instead!

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

Tween between yellow and blue, using HUSL. No cheating this time.
1
2
3
4
5
6
7
8
9
10
var start = HUSL.fromHex("#FFFF00");
var end = HUSL.fromHex("#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(HUSL.toHex(h(i/6), s(i/6), l(i/6)));
}

And here is what we get out:

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

Quick, let’s make a gradient!

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

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.

Let's compare: RGB, HSL, HUSL.