The least wrong colors, version 2

An updated algorithm, npm package, and UI for color palettes

Sep 08, 2026 · 10 min read

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:

  1. Similarity — a way of measuring the similarity of one palette to another, useful for providing art direction and getting brand alignment
  2. Energy — the colors should be different from each other so they aren’t liable to be confused from one another
  3. Range — the differences between the colors should be consistent so unintended groupings don’t appear
  4. 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:

  1. JND — strongly reject palettes that have two or more colors that are too similar
  2. Avoid — the mirror image of the similarity evaluation, push colors away from a user-defined set
  3. 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
  4. Saliency — uses color naming study data to prefer colors that are easy to name
  5. 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
All numbers are at 8 colors. Rows with ± are mean ± standard deviation over 10 palettes; rows without are deterministic and produce one palette. category-colors and Palettailor are 10 independent runs on the same seeds; Colorgorical’s row is 10 palettes from its authors’ own sampling script at equal criterion weights. QualPal was run with CVD on, matched bounds, and takes no seed. Name difference is Heer & Stone’s 1 − cosine; Colorgorical’s own interface reports a Hellinger distance instead. Shaded cells are the best value in their column.

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
Same run: 8 colors, 10 trials. Reference palettes are deterministic, so they're single values. Shaded cells are the best value in their column.

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.

It’s ridiculously overengineered, but hey, what else are personal projects for?

Acknowledgements

Many measurements come from published research: Gaurav Sharma, Wencheng Wu and Edul Dalal for CIEDE2000; Gustavo Machado, Manuel Oliveira and Leandro Fernandes for the color vision deficiency simulation; Maureen Stone, Danielle Albers Szafir and Vidya Setlur for the size-dependent just-noticeable-difference result; Jeffrey Heer and Maureen Stone, whose color naming models and the c3 data from the Stanford Visualization Group power both the saliency and name-difference evaluators.

Existing palettes: Masataka Okabe and Kei Ito’s Color Universal Design set; Matthew Petroff’s sequences; and Mark Harrower and Cynthia Brewer’s ColorBrewer.

Other generators laid a lot of the groundwork: Kecheng Lu and colleagues (Palettailor), Connor Gramazio, David Laidlaw and Karen Schloss (Colorgorical), Johan Larsson (QualPal), and Chin Tseng, Arran Zeyu Wang, Ghulam Jilani Quadri and Danielle Albers Szafir (CatPAW). Andrew McNutt, Maureen Stone and Jeffrey Heer’s color-buddy has also been indispensable.

Finally, Dan Burzo’s culori made it easy to make this library colorspace-agnostic.