Skip to content
Devix Open Source

Guide

Colour, contrast and OKLCH

OKLCH, and colours a screen cannot show

OKLCH is how CSS now describes colour perceptually and how Tailwind v4 writes its palette. Its lightness matches what the eye sees: oklch(70% 0.15 250) and oklch(70% 0.15 30) really do look equally bright, where hsl(60 100% 50%) and hsl(240 100% 50%) claim the same lightness and are yellow and navy.

That is what makes a palette possible from one colour — keep the hue and chroma, move only the lightness, and every shade looks like the same colour:

import { parse, rgbToOklch, oklchToRgb, format } from '@devix-labs/color-picker';

const seed = rgbToOklch(parse('#8338ec'));
const ramp = [95, 90, 80, 70, 60, 50, 40, 30, 20, 10]
  .map((l) => format(oklchToRgb({ ...seed, l: l / 100 }), 'hex'));

The conversion uses Björn Ottosson's own matrices and is checked against the figures he published: #ff0000 is oklch(62.80% 0.2577 29.23), #00ff00 is oklch(86.64% 0.2948 142.50), #0000ff is oklch(45.20% 0.3132 264.05).

The gamut

OKLCH can name colours no sRGB screen can produce — oklch(70% 0.37 150) is one of them. Every other picker clips those without telling you, so the value you typed is not the value you keep. This one shows the nearest colour it can, adds .dxc--out-of-gamut to the widget, and announces it in the live region.

You can ask the same question yourself:

import { parse, parseOklch, inSrgbGamut, rgbToOklch } from '@devix-labs/color-picker';

const asked = parseOklch('oklch(70% 0.37 150)');   // { l: 0.7, c: 0.37, h: 150, a: 1 }
inSrgbGamut(asked);                                // false
rgbToOklch(parse('oklch(70% 0.37 150)'));          // the nearest colour sRGB can show

parse() always returns something a screen can display. parseOklch() returns what was written. The difference between the two is the warning.

Contrast

createColorPicker(input, { contrastAgainst: '#ffffff' });

Under the picker: Contrast 4.6 to 1 — AA. That is the check that otherwise happens by hand, in another tab, after the colour has already been chosen and shared.

Translucent colours are composited over that background first, because #00000080 is not black. The rating follows WCAG 2.1:

Rating Ratio Means
aaa 7 or more Passes AAA for body text.
aa 4.5 or more Passes AA for body text.
aa-large 3 or more Passes AA for 18pt, or 14pt bold.
fail below 3 Not enough for text at any size.

The note carries data-rating, so you can colour it yourself.

The maths, without the widget

Everything above is exported on its own — for a design-token pipeline, a CI check that a palette stays readable, or a server-rendered badge:

import { parse, format, contrast, rate, readableOn, over } from '@devix-labs/color-picker';

contrast(parse('#3a86ff'), parse('#ffffff'));   // 3.48
rate(3.48);                                     // 'aa-large'
format(readableOn(parse('#ffbe0b')), 'hex');    // '#000000' — black reads on amber
format(over(parse('#00000080'), parse('#fff')), 'hex');   // '#808080'
Export What it does
parse(string) Any notation to { r, g, b, a }, or null.
parseOklch(string) What an oklch() string asked for, before sRGB has its say.
format(rgb, 'hex' | 'rgb' | 'hsl' | 'oklch') Back to a CSS string.
rgbToHsv hsvToRgb rgbToHsl hslToRgb rgbToOklch oklchToRgb The conversions, all lossless round trips.
inSrgbGamut(oklch) Whether a screen can show it.
contrast(a, b) The WCAG 2.1 ratio, rounded to two places.
rate(ratio) 'aaa' | 'aa' | 'aa-large' | 'fail'.
readableOn(rgb) Black or white, whichever reads better on it.
over(colour, background) Composites a translucent colour, so it can be judged.

These are the whole colour core: about 2 kB of the package, tree-shaken away if you only import the widget, and importable without it.

Updated 15 Sep 2026