Exploring Uniform Colors in OKLCH color space

Published on Mar 5, 2024

In the pursuit of designing visually appealing and coherent interfaces, the choice of color palettes plays a pivotal role. Traditionally, many developers, including myself, have relied on the HSL color space to generate uniform color schemes. However, despite its straightforwardness, HSL often leads to inconsistent perceptions of color, mainly due to its lack of perceptual uniformity. This inconsistency was the catalyst for my exploration into an alternative approach, leading me to the OKLCH color space.

What we are trying to achieve?

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

OKLCH color space is a great alternative to HSL for creating color palettes that are perceptually uniform. The above examples show how we can use the OKLCH color space to create a palette of colors that are consistent and harmonious across a range of hues and lightness values. The perceptual uniformity of the OKLCH color space makes it an excellent choice for creating color palettes that are visually appealing and coherent.