Four years ago, I wrote “How to pick the least wrong colors.” The gist is: picking a categorical color palette is an optimization problem. There’s no such thing as the right colors. But if you use the right cost function, and the right kind of hill climbing, you can at least get the least wrong ones.
Since the original post I’ve been slowly picking away at improvements and new approaches. Now that we’re past the singularity, I’ve put a few coding robots on the job. It’s reassuring that many of my assumptions were good ones! The robots have been able to improve the code, bridging some of the gaps in my own knowledge.
Today, I’m publishing an updated version of the algorithm as an npm package, along with a fancy GUI version. While there’s still more to do, I’m proud of how far I’ve been able to take it.
What’s new
New evaluators
Almost as soon as I published the first version, I realized that the cost function lends itself really well to modularity. Beyond my initial evaluation functions, I could design new ones, and provide a framework for anyone to plug in their own. As a recap, my original criteria for good categorical colors, mapped to evaluation functions:
- Similarity — a way of measuring the similarity of one palette to another, useful for providing art direction and getting brand alignment
- Energy — the colors should be different from each other so they aren’t liable to be confused from one another
- Range — the differences between the colors should be consistent so unintended groupings don’t appear
- Color vision deficiency — simulating the colors under different types of color blindness (red-green, blue-yellow, partial to full tritanopia)
Here’s the new evaluators:
- JND — strongly reject palettes that have two or more colors that are too similar
- Avoid — the mirror image of the similarity evaluation, push colors away from a user-defined set
- Contrast — compares colors, keeping them above the WCAG AA color contrast floor. Can be used with a background color to maintain contrast on a chart’s background
- Saliency — uses color naming study data to prefer colors that are easy to name
- Name difference — the mirror image of saliency, avoiding colors that share names
Each of these evaluators can be weighted, indicating the kinds of tradeoffs and priorities you’d like for your color palette.
Additionally, the whole evaluator system is pluggable: you can define your own evaluators and have them drive the optimizer!
More controls
Colors can now be fixed in place, or pinned to a particular order, making it easier to load in existing palettes and optimize all or just some of the colors.
Individual channels of each color can be locked, too, meaning you can keep the saturation or hue of a color fixed while optimizing its lightness. This works in any color space.
The public API and a CLI
The whole package is now a proper library, with a public API. This means: 1. the whole thing is now distributable through npm, with proper versioning, 2. there’s a CLI, making it much more ergonomic for both humans and agents.
The API allows for full configuration of the algorithm, as well as loading in colors to optimize. Output can be in raw color values, CSS properties, or DTCG JSON.
There’s also a new reportJndIssues endpoint that allows you to evaluate palettes without optimizing them, which is useful to compare a generated palette to commonly-used ones (like Observable, d3, IBM Carbon, and more).
What’s improved
The annealing algorithm
When I wrote the initial algorithm in 2022, I had just learned about simulated annealing. I’ll be honest: I don’t know much more today than I did then. But with AI-assisted research, I was able to solve some questions I had about the initial implementation. Now, the algorithm picks the correct starting temperature based on some random initial samples. Mutation also happens in a scaled manner, so colors change less towards the end of the optimization schedule. Iterations can be capped to prevent very long runs, and the whole thing is much, much more performant.
Configurable color space and distance metric
The first version of the algorithm worked in RGB space. Now, it defaults to okhsl, but even this is configurable. Individual channels can be constrained to dial in the palette’s boundaries.
Also, you can choose which color distance metric you’d like to use (but the library uses CIEDE2000 by default).
This flexibility is powered largely by a move from chroma.js to culori. I’ve learned a ton about color spaces since 2022, so being able to mix and match color spaces with distance metrics has been extremely useful.
The results
The category-colors library reliably produces better results than other palette-generating tools and industry-standard color palettes.
Compared to other palette-generating tools, category-colors has more control. Palettailor, for example, optimizes for pure color difference, without accounting for color vision deficiency. QualPal brings some of the optimization parameters, but doesn’t allow for steering towards or away from arbitrary colors.
| Scores at 8 colors | ||||
|---|---|---|---|---|
| ΔEMinimum | ΔEworst of CVD | Name differenceMinimum | Uniformitylower is better | |
| category-colors | 22.6 ±1.6 | 13.7 ±2.0 | 0.35 ±0.14 best in column | 0.30 ±0.02 best in column |
| QualPal 1.1.0 | 24.7 | 21.8 best in column | 0.10 | 0.44 |
| Palettailor | 26.6 ±2.4 best in column | 4.5 ±1.7 | 0.34 ±0.16 | 0.34 ±0.04 |
| Colorgorical | 15.8 ±3.3 | 4.1 ±1.6 | 0.09 ±0.06 | 0.42 ±0.03 |
Compared to industry-standard palettes, category-colors can produce more optimal palettes, especially at high cardinality.
| Scores at 8 colors | ||||
|---|---|---|---|---|
| ΔEMinimum | ΔEworst of CVD | Name differenceMinimum | Uniformitylower is better | |
| category-colors | 22.6 ±1.6 best in column | 13.7 ±2.0 best in column | 0.35 ±0.14 | 0.30 ±0.02 best in column |
| Okabe–Ito | 21.3 | 8.8 | 0.06 | 0.34 |
| Observable 10 | 18.4 | 0.6 | 0.40 | 0.34 |
| Tableau 10 | 18.1 | 3.2 | 0.24 | 0.32 |
| d3 category10 | 16.2 | 1.6 | 0.84 best in column | 0.40 |
| ColorBrewer Set3 | 13.7 | 1.9 | 0.16 | 0.32 |
| IBM Carbon | 12.8 | 5.0 | 0.11 | 0.34 |
One more thing
I’ve built a UI that consumes the package and makes it easy to generate and optimize palettes. This has been the biggest request since I published the initial essay, so it’s the thing I’m excited to share.
