Francisco Brusa

Generating color palettes with code

Most monitors use the sRGB color spectrum, which basically means they can represent a limited subset of the colors we can see in real life.

Visual representation of the sRGB color spectrum inside the 'shoe-shaped' visual spectrum

sRGB color spectrum (black triangle), as compared to the color range visible by the human eye ("shoe shape").

Via Wikimedia Commons, CC BY-SA 3.0

When you see an hexadecimal color (like #FF0000) or a CSS color value (like rgb(100,0,0)), you are seeing the representation of a color inside the sRGB spectrum, the color scheme used by the browser. These are instructions that the browser sends to your screen to render a color, by adding light from red, green and blue LEDs (each pixel contains these three colors, that combined can achieve white).

As a side-note, not all monitors use sRGB; for example, Apple monitors use Apple RGB or Display-P3, spectrums that allow access to a wider color gamut than sRGB. But this color spaces are not industry standards (yet?), and CSS is not prepared to use them.

Update: The CSS spec (color level 4 and 5) standardizes support for new color spaces. Right now it only works in Safari Tech Preview. More info here.

There are many ways to represent sRGB colors, the most common one is the hexadecimal notation, which works by setting a value for Red, Green and Blue.

Hex: #f767bb

Although the resulting hexadecimal value is a short string, which makes it convenient to share and store, this method for choosing colors is not particularly intuitive for humans. We don't often think about colors as the addition of other colors. To have a better mental model, HSL was created.

Hue, saturation, luminosity

As an alternative to rgb(), hsl() is a CSS function used to express any hexadecimal color. It's called HSL because it receives three arguments: Hue, Saturation and Luminosity.

This function allows for more intuitive ways to tweak its values. For example, we can set a fixed value for hue and saturation, and increment lightness by 10%, from 0 to 100 (11 steps in total), to get a palette of any color.

This is the result of going from hsl(0, 0%, 0%) to hsl(0, 0%, 100%), by steps of 10%. Since the saturation is 0, this will get a palette of grays.

But because we don't want to only use grays, let's see how to obtain colors using hsl().

hsl(0, 0%, 50%)

If we increase saturation, (the s in hsl()) we can see how color starts to appear. In this case, since our hue is 0, we will see the color red.

Try it yourself: move the saturation slider until you see the box fill up in red.

hsl(0, 100%, 50%)

We can also change hue (the h in hsl()), using values from 0 to 360, to get colors from all the sRGB spectrum.

Generating color palettes

Given all we've discovered so far, we could try to create palettes dynamically using the arguments of the hsl() function.

Here we have an implementation of the gray palette using for-loops in Sass.

.palette.gray {
$hue: 0;
$saturation: 0%;
@for $i from 0 through 10 {
.step:nth-child(#{$i + 1}) {
background-color: hsl($hue, $saturation, $i * 10%);
}
}
}

The result looks like this:

We can also use JSX to express this color palette, with a component like this:

export const ColorPalette = ({ hue = 0, saturation = 100 }) => {
return (
<div style={{ display: "grid", gridTemplateRows: "repeat(11, 1fr)" }}>
{[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100].map((lightness) => (
<div
key={lightness}
style={{
backgroundColor: `hsl(${hue}, ${saturation}%, ${lightness}%)`,
aspectRatio: "1 / 1",
}}
/>
))}
</div>
);
};

This component can then be used like this:

<ColorPalette hue={325} saturation={90} />

Try it yourself! Move the sliders to see how the component reacts to different values of hue and saturation.

Let's go ahead and generate a palette for different hues, by using our ColorPalette component multiple times in the following way:

<ColorPalette hue={0} />
<ColorPalette hue={60} />
<ColorPalette hue={180} />
<ColorPalette hue={230} />
<ColorPalette hue={270} />

You might think this is a good method to generate color palettes for graphic design or the web. After all, the result we got surely must be a harmonic color palette... right? Well, as we can see, not so much. We have inconsistencies all around!

For example, even at the same luminosity of 50%, blue text is perfectly readable at a white background, while yellow text is only readable with a black background.

According to the HSL function, these colors have the same luminosity. But do these colors really have the same luminosity? 🧐

Other colors have steps that are almost impossible to distinguish.

For example, in the following sample we have two colors at 50% and 60% luminosity. While for purple we can clearly perceive two different "shadows", the two shadows of teal are almost impossible to tell apart.

To make things worse, the value for hue does not change uniformly either. The colors from the top in the following image have the same hue difference as the blue colors from the bottom. Both differ by 30 degrees. Yet, the hue difference is much more noticeable for the orange and yellow.

And let's not even look into start changing saturation, because that value tends to be misleading as well!

Therefore, this method to generate palettes is not reliable. You do not want to use colors generated this way to generate button variants, or any other piece of UI where legibility and consistency is important.

As we've seen, you can't just "do math" with the params of hsl(). The same problems occur with Sass' lighten() and darken() functions.

Why is HSL so "broken"?

The problem with HSL

Let's look again at the sRGB spectrum inside the "shoe-shaped" figure (this "shoe shape" is actually a representation of the CIE XYZ color space, a color space that takes into account psychological perception).

In this figure, line a represents the distance of teal at 50% and 75% luminosity.

Line b represents the distance of blue at 50% and 75% luminosity.

According to the HSL function, the distance variation is the same, 25% for both cases.

But, are this distances really the same? 🧐

Well, as we can see in the graphic, clearly not.

If we only consider "the amount of power the LED pixel is using", we could say the screen is using, for both cases, 50% and 75% of it's full capacity.

But if we compare this output with a model that takes into account psychological perception, we can see that the screen is limited when representing certain colors, which causes distortions in the results of HSL.

HSL was created to be fast, not to be perceptually accurate. It inherits the flaws of RGB: both are models based on how monitors output color, not on how humans perceive it.

Luckily, smarter people have come up with solutions and alternatives to deal with color in code. We'll explore one of them, HSLuv.

HSLuv:
The human-friendly alternative to HSL

HSLuv is a color space designed for perceptual uniformity.

It's based on the CIELUV color space, created in 1976 based on experiments that tried to "marry" the distributions of color wavelengths with the psychologically perceived colors in human vision.

Which basically means that HSLuv makes it easy to reason about color.

It is commonly used for:

  1. Generating colors for statistical graphics and data visualization software.
  2. Generating themes, like this neat code syntax highlighter theme generator.
  3. Build color palettes with consistent contrast ratios, like the one created for Polaris, Shopify's design system.

HSLuv has dozens of open-source implementations for different programming languages. The JavaScript implementation has a tiny footprint in bundle size (2kb) and a very simple API.

import hsluv from "hsluv";
// Convert hsluv to hex
hsluv.hsluvToHex([hue, saturation, lightness]);
// Convert hex to hsluv
hsluv.hexToHsluv(hex);

Once we have a HSLuv value, changing it's lightness, even across different hues, will produce somewhat uniform results.

Let's look at an implementation of the ColorPalette component using HSLuv. To achieve this, we only need to make a small change in line 8.

export const ColorPalette = ({ hue = 0, saturation = 100 }) => {
return (
<div style={{ display: "grid", gridTemplateRows: "repeat(11, 1fr)" }}>
{[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100].map((lightness) => (
<div
key={lightness}
style={{
backgroundColor: hsluv.hsluvToHex([hue, saturation, lightness]),
aspectRatio: "1 / 1",
}}
/>
))}
</div>
);
};

Let's try to generate the same palettes as before, and compare the results.

The code:

<ColorPalette hue={0} />
<ColorPalette hue={60} />
<ColorPalette hue={180} />
<ColorPalette hue={230} />
<ColorPalette hue={270} />

HSL implementation (for reference):

HSLuv implementation:

You might notice that colors outputted by this method look more uniform.

Let's look for example at the red color: it looks like magenta in HSLuv.

A Hue of 0 produces different reds in HSL and HSLuv.

The Hue for red in HSL might not necessarily have the same value in HSLuv. Since HSLuv makes some corrections in the Hue dimension to achieve uniformity, we need adjustments to get the same red as with HSL.

HSL can easily be converted to hexadecimal (for example, red is #ff0000) and we can convert that value to HSLuv using this function:

hsluv.hexToHsluv("#ff0000");
// [12.177050630061776, 100.0000000000022, 53.23711559542933]

As we can see, HSLuv needs a value of 12 for hue to show red. (Technically, 12.1770..., but we will round it up). So, let's paint the palette again after making this adjustments for all the colors...

HSL implementation (for reference):

HSLuv implementation:

And voilà.

As you can see, the HSLuv implementation is much better at keeping consistency and preserving contrast for different colors.

Still, according to personal preference, some optical adjustments might be needed. For example, HSLuv tends to remove too much saturation and makes some colors look dull.

For some scenarios, it might make sense to sacrifice some uniformity in the contrast dimension in pro of having more saturated colors. This tradeoff might make sense for some cases and not for others, so it's impossible for a fully automated tool to make this kind of judgments.

Side-note: alternative color models

As a side-note, HSLuv is not the only color space that achieves perceptual uniformity. Although it's results are good enough for changes in luminosity only, if you also want to make gradient changes in Hue or Saturation, other color spaces are better-suited.

In particular, CIECAM02-UCS performs much better for this use cases (thanks Nate Baldwin for pointing this out). If you want to try it out, there's a D3 module to work with this color appearance model, and the Leonardo Color library (a tool specifically created to generate color palettes) uses this model by default.

Other alternative tools like Chroma.js can be used to generate palettes using alternative models like LAB, LCH, CMYK, etc. Some of this models work better for certain scenarios.

For this article, I sticked with HSLuv for some simple reasons:

  1. Minimal bundle-size. It's 2kb of JS in total, vs. ~14kb for Chroma.js and ~88kb for Leonardo Color. (Source: bundle-phobia)
  2. "Good enough" results. I tested the palettes we generated in the article with all three tools. The differences where almost unnoticeable. (Again, this can change if you're changing other dimensions besides luminosity or trying different things. For example, I found Chroma.js has better APIs than HSLuv to create gradients between colors, and Leonardo Color is the only tool that takes into account the psychological effect that the background color has in the perception of the foreground color).
  3. Simple API. No need to understand different color spaces or concepts like interpolation. Or any other technical knowledge besides the basics, for that matter.

Again, there are always tradeoffs. If you don't need to generate color palettes dynamically, I recommend you to check the Leonardo Color web UI. It uses the CIECAM02 color space by default, which produces some nice gradients between colors.

Conclusion

Don't do math with color when using RGB, HSL, or Sass functions. CSS and Sass use a color model that doesn't guarantee consistency.

Instead, use an alternative color space. If you're a frontend developer, you will need JS for this, but some small libraries like HSLuv can perform a pretty good job.

HSLuv makes reasoning about color, and doing "math" with colors, a much more intuitive process.


Links of interest: