Exploring Uniform Colors in OKLCH color space

Published on Mar 5, 2024

HSL is the default for most developers generating color palettes programmatically - it’s simple and familiar. The problem is that equal steps in HSL don’t produce colors that look equally different to a human eye. A palette that looks balanced in code can look uneven on screen.

OKLCH is built around how humans actually perceive color. The same step in lightness or chroma looks like the same visual difference regardless of hue. That property is what makes it useful for generating palettes that hold together across a full range of colors - and why this site’s color system is built on it.

The goal

Finding uniform colors is rather tricky. We want to find a color palette that allows us to have different shades of colors but with same intensity. Eventually, we want to generate color palettes for tailwindcss that are uniform and consistent.

The Quest for Uniformity

The OKLCH color space stands out because it is designed to be perceptually uniform. It is a cylindrical transformation of the CIELAB color space, which is itself designed around human vision. This means colors in OKLCH are spaced in a way that corresponds more closely to how we perceive differences in color. This uniformity makes OKLCH an excellent choice for creating color palettes that are consistent and harmonious across a range of hues.

OKLCH colorspace is defined by three parameters: lightness, chroma, and hue. Lightness is the perceived brightness of the color, while chroma is the colorfulness of the color. Hue is the attribute that distinguishes one color from another. The OKLCH color space has boundaries beyond which colors can not be displayed. Because of this property, we can not simply divide the colorspace into equal intervals to create a palette. Instead, we need to select lightness and chroma values that allow for a broad spectrum of colors to be displayed.

Selecting Lightness and Chroma Values

The idea is simple. For a given lightness in OkLCH colorspace, we can find a maximum chroma value where the entire hue range has displayable colors. This maximum chroma value is different for each lightness value and needs to be calculated. I used the following code (deno inside a Jupyter notebook) to map lightness to maximum chroma values. There might be an easier way to do this, but the following works:

// @ts-ignore
import { readJsonSync, writeJsonSync } from 'https://deno.land/std@0.52.0/fs/mod.ts';

// @ts-ignore
import { useMode, modeOklch, modeRgb, formatHex, displayable } from 'npm:culori/fn';

const rgb = useMode(modeRgb);
const oklch = useMode(modeOklch);

const oklchToRgb = function (l: number, c: number, h: number) {
	const color = rgb(oklch({ l: l, c: c, h: h }));
	if (displayable(color)) return formatHex(color);
};

const hr = Array.from({ length: 360 }, (_, i) => i);
const cr = Array.from({ length: 4000 }, (_, i) => i / 10000);
const lr = Array.from({ length: 10000 }, (_, i) => 1 - i / 10000);

const invalidHue = function (l: number, c: number) {
	for (let h of hr) {
		const color = oklchToRgb(l, c, h);
		if (!color) return [l, c, h];
	}
	return [];
};

// we should be able to speed this up using bisection,
// but that would not give optimal results.
// Moreover, the calculation will be cached, anyway.
const maxChroma = function (l: number) {
	let prev = 0;
	for (let c of cr) {
		const invalid = invalidHue(l, c);
		if (invalid.length > 0) {
			return [prev, invalid];
		}
		prev = c;
	}
};

const cachedLCMap = function (path: string) {
	const mappedLC = function () {
		const map = {};
		for (let l of lr) {
			const res = maxChroma(l);
			// @ts-ignore
			const ls = Math.round(parseFloat(l) * 10000) / 10000;
			// @ts-ignore
			map[ls] = res.length > 0 ? res[0] : 0;
		}
		return map;
	};

	let map = readJsonSync(path, 'utf8');
	if (map && Object.entries(map).length > 0) return map;

	console.log('caculating boundaries..');
	map = mappedLC();
	writeJsonSync(path, map, 'utf8');
	return map;
};

const map: { [key: number]: number } = cachedLCMap('../src/routes/colors/mapping.json');

Creating a Palette with consistent Lightness (and Chroma) while varying Hue

Using the above code we can get a mapping of lightness to maximum chroma values. This mapping can be used to create a palette of colors that are perceptually uniform. The following code uses the mapping to create a palette of colors (in Svelte):

<script context="module" lang="ts">import { lchChromaMap } from "$lib/store";
</script>

<script lang="ts">export let lightness;
export let chroma = -1;
const map = $lchChromaMap;
const hr = Array.from({ length: 100 }, (x, i) => i * 3.6);
chroma = chroma > 0 ? chroma : map[lightness];
</script>

<div class="flex mb-8">
	{#each hr as h}
		<div class="w-12 h-12" style="background-color: oklch({lightness * 100}%, {chroma}, {h})"></div>
	{/each}
</div>

Using the above component, we can construct a slider to select lightness and see the palette of colors that can be displayed at that lightness value. I would expect a consistent palette of uniform colors to be displayed as I move the slider. Let’s see if that’s the case.

Creating a Palette with consistent Hue (and Chroma) while varying Lightness

What if we want to keep the hue constant and only vary the lightness to display the color at different brightness? A slight modification of the PaletteHue component gives us:

<script context="module" lang="ts">import { lchChromaMap } from "$lib/store";
</script>

<script lang="ts">export let hue;
export let chroma = 0.04;
const map = $lchChromaMap;
const lr = Array.from({ length: 198 }, (x, i) => (i + 1) / 200);
</script>

<div class="flex mt-4 mb-8">
	{#each lr as l}
		<div
			class="w-12 h-12"
			style="background-color: oklch({l * 100}%, {map[l] > chroma ? chroma : map[l]}, {hue})"
		></div>
	{/each}
</div>

Conclusion

The lightness-to-chroma mapping is the key insight. Once you have that precomputed, generating perceptually uniform palettes is straightforward - pick a lightness, use the max chroma for that lightness, vary the hue. The sliders above show what that looks like in practice.

The palette generator, gradient generator, and theme builder on this site all use this approach.