• UX
  • audio
  • data
  • code

Recursion! In the stylesheet

If you need to determine the color at a point along a gradient, you could use Javascript. For example, in the past I’ve used a D3 color scale with an appropriate interpolator. But I realized that you can nest CSS color-mix() functions to accomplish the same thing. It just takes a smidge of algebra.

The goal in this simple example is to have an element whose fill color is a color along that gradient, as determined by a custom property, call it --t. By changing the value of --t from 0 to 1, we can “pick” a color from a gradient.

The simple two-color case

To see how, let’s start with the simplest case of a gradient with just two stops.

.gradient {
	background-image: linear-gradient(to right, red, blue);
}

It’s straightforward to see how we can use a color-mix() function to achieve this in this simple, two-color case. When --t is 0, we want to mix[1] 100% red with 0% blue, and when --t is 1, we’ll mix 0% red with 100% blue.

.mix {
	--t: 0;
	background-color: color-mix(in srgb, red, blue calc(var(--t) * 100%));
}

Moving a gradient stop

That’s simple enough. To make it a little more complicated, let’s make it not a 50/50 split by moving the blue gradient stop to the left.

.gradient {
	background-image: linear-gradient(to right, red, blue 30%);
}

To deal with this, we need to adjust the input to color-mix() so that when --t reaches .3, the second color of our color-mix() is already at 100% rather than 30%. Specifically, we’ll divide --t by .3. Then, to make sure it stays in the range of 0–100%, we clamp() it.

.mix {
	--t: 0;
	background-color: color-mix(in srgb, red, blue clamp(0%, var(--t) / .3 * 100%, 100%));
}

Recursion for more than two colors

Now for the recursive part: let’s add a third color to the mix.

.gradient {
	background-image: linear-gradient(to right, red, blue 30%, coral 80%);
}

What we need is a color-mix() function that mixes red and blue when --t is between 0 and .3, then mixes blue and coral when --t is between .3 and .8, and coral after that.

For that, a bit of algebra. In the previous example we came up with a line that goes from 0 to 1 as --t goes from 0 to .3. That’s a specific case of this more general equation for a line whose output goes from 0 to 1:

y=tt1t0t0t1t0 y = \frac{t}{t_1 - t_0} - \frac{t_0}{t_1 - t_0}

So we need two lines that go from 0 to 1: one between 0 and .3, and one between .3 and .8.

Graph of --t versus input to each color-mix function

The outer layer of recursion then mixes red and something as --t goes from 0 to .3;

.mix {
	--t: 0;
	background-color: color-mix(in srgb, red, ??? clamp(0%, var(--t) / .3 * 100%, 100%));
}

What is ???? It’s another color-mix() that goes from blue to coral as --t goes from .3 to .8. By itself, that would be

color-mix(in srgb, blue, coral clamp(0%, (var(--t) / (.8 - .3) - .3 / (.8 - .3)) * 100%, 100%));

When we drop that into the ??? of the outer mixture, when --t is less than .3, the second color is blue, so we mix between red and blue. When --t is greater than .3, it’s 100% the inner mixture, which itself runs from blue to coral.

.mix {
	--t: 0;
	background-color: color-mix(
		in srgb,
		red,
		color-mix(
			in srgb,
			blue,
			coral
			clamp(0%, (var(--t) / (.8 - .3) - .3 / (.8 - .3)) * 100%, 100%)
		)
		clamp(0%, var(--t) / .3 * 100%, 100%)
	);
}

Generating it with Sass

Working that out by hand is fine for just a few gradient stops, but for arbitrary gradients it becomes pretty unwieldy. For that, we can use Sass to generate the CSS. I’ll admit, I’m not a Sass expert; I mostly use plain old CSS these days. Still, generating nontrivial, recursively nested CSS functions seemed like a great use for a CSS complier.

First, I declare the configuration variables: a Sass list-of-lists representing the gradient stops, and the color space. As mentioned in my footnote previously, I’m using sRGB for browser compatibility, but you can change it to other color spaces to see how it behaves in not-Firefox.

$gradient-stops: (
	(red, 0),
	(orange, .166666),
	(yellow, .333333),
	(green, .5),
	(blue, .666666),
	(indigo, .833333),
	(violet, 1),
);

// Firefox only supports sRGB.
// Support for other color spaces is relatively new in other browsers.
$space: "srgb";

Then, there are two @functions. One maps $gradient-stops to a set of gradient stops that can be used in linear-gradient() CSS function, and the other generates a set of nested color-mix() CSS functions that implements the method I described above.

That function, called cmix(), has one required argument, a set of gradient stops, plus two additional arguments that help with recursion: an iterator counter, and an accumulator. When on less than the second-to-last stop, cmix() calls itself with the iterator incremented. Then, the important part is this: when not on the last gradient stop, it takes the current color and progress, and the next color and progress, and outputs a color-mix() function with the math from before:

clamp(0%, (var(--t) / ($p1 - $p0) - math.div($p0, $p1 - $p0)) * 100%, 100%)

The whole function is too boring to reproduce in its entirety in a blog post, but you can check it out on CodePen. Feel free to reach out on Mastodon with comments (especially criticism of how I implemented a recursive algorithm 😅).


  1. You’ll notice that I’m using the srgb color space in all of my examples. That’s because color-mix() and the gradient need to be interpolating in the same color space as each other. Sadly, Firefox doesn’t yet have support for other color spaces in gradients. The Sass function in the accompanying CodePen can be configured with a color space so you can see how it works in supported browsers. ↩︎