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.