An Advanced Color Picker With OKLCh, LCh, LAB, and Color Harmony
CSS now supports
oklch()β a perceptually uniform color space that makes gradients look natural and lets you lighten/darken colors without hue shifts. But most color pickers still only know RGB and HSL. This one supports six color spaces (RGB, HSL, HSV, LAB, LCh, OKLCh) with sliders that stay in sync, plus color harmony generation and contrast checking.
OKLCh is the modern CSS color format you should be using. Lightness is perceptually uniform (step 0.1 L looks like the same perceptual distance everywhere). Chroma is the "intensity" of the color. Hue is an angle. The problem: barely any tools let you dial in OKLCh values directly.
π Live demo: https://sen.ltd/portfolio/color-picker-pro/
π¦ GitHub: https://github.com/sen-ltd/color-picker-pro
Features:
- 6 color spaces: RGB, HSL, HSV, LAB, LCh, OKLCh
- All formats output (hex, rgb(), hsl(), oklch(), etc.)
- Live-synchronized sliders
- 6 harmony patterns (complementary, triadic, analogous, split-comp, tetradic, monochromatic)
- Contrast checker vs white/black (WCAG AA/AAA)
- Color blindness preview
- EyeDropper API for picking from screen
- Recent colors history
- Japanese / English UI
- Zero dependencies, 97 tests
BjΓΆrn Ottosson's OKLab matrices
OKLab is a perceptually uniform color space published by BjΓΆrn Ottosson in 2020. Converting from linear RGB takes two matrix multiplications with a cube root in between:
// Linear RGB β LMS (cone response)
const LMS_MATRIX = [
[0.4122214708, 0.5363325363, 0.0514459929],
[0.2119034982, 0.6806995451, 0.1073969566],
[0.0883024619, 0.2817188376, 0.6299787005],
];
// LMS^(1/3) β OKLab
const OKLAB_MATRIX = [
[0.2104542553, 0.7936177850, -0.0040720468],
[1.9779984951, -2.4285922050, 0.4505937099],
[0.0259040371, 0.7827717662, -0.8086757660],
];
export function rgbToOklab(r, g, b) {
const lr = sRGBToLinear(r / 255);
const lg = sRGBToLinear(g / 255);
const lb = sRGBToLinear(b / 255);
// Step 1: RGB β LMS
const l = LMS_MATRIX[0][0]*lr + LMS_MATRIX[0][1]*lg + LMS_MATRIX[0][2]*lb;
const m = LMS_MATRIX[1][0]*lr + LMS_MATRIX[1][1]*lg + LMS_MATRIX[1][2]*lb;
const s = LMS_MATRIX[2][0]*lr + LMS_MATRIX[2][1]*lg + LMS_MATRIX[2][2]*lb;
// Step 2: cube root (cone response compression)
const l_ = Math.cbrt(l);
const m_ = Math.cbrt(m);
const s_ = Math.cbrt(s);
// Step 3: LMS_ β OKLab
return {
L: OKLAB_MATRIX[0][0]*l_ + OKLAB_MATRIX[0][1]*m_ + OKLAB_MATRIX[0][2]*s_,
a: OKLAB_MATRIX[1][0]*l_ + OKLAB_MATRIX[1][1]*m_ + OKLAB_MATRIX[1][2]*s_,
b: OKLAB_MATRIX[2][0]*l_ + OKLAB_MATRIX[2][1]*m_ + OKLAB_MATRIX[2][2]*s_,
};
}
The cube root is the key step β it compresses cone responses to match human perception of lightness. Without it, you'd have a linear-looking space that doesn't feel uniform.
OKLCh = OKLab in polar coordinates
OKLCh converts the a/b axes of OKLab into polar coordinates:
export function oklabToOklch(L, a, b) {
const C = Math.sqrt(a*a + b*b);
const h = Math.atan2(b, a) * 180 / Math.PI;
return { L, C, h: h < 0 ? h + 360 : h };
}
- L (lightness): 0 to 1
- C (chroma): 0 to ~0.4 for displayable colors
- h (hue): 0 to 360 degrees
The practical advantage: changing hue at constant L and C preserves perceived brightness. If you want "the same color but blue instead of red", you just change h. In HSL, changing hue drastically changes perceived brightness (pure yellow looks much brighter than pure blue at the same HSL lightness).
Color harmony
Classical design color harmony is just angle operations on hue:
export function complementary(hsl) {
return [hsl, { ...hsl, h: (hsl.h + 180) % 360 }];
}
export function triadic(hsl) {
return [
hsl,
{ ...hsl, h: (hsl.h + 120) % 360 },
{ ...hsl, h: (hsl.h + 240) % 360 },
];
}
export function analogous(hsl, count = 5) {
const step = 30;
const result = [];
for (let i = -Math.floor(count / 2); i <= Math.floor(count / 2); i++) {
result.push({ ...hsl, h: (hsl.h + i * step + 360) % 360 });
}
return result;
}
These work in HSL but would work equally well in OKLCh β and arguably better, because the perceptual uniformity means the resulting harmonies look "evenly spaced" instead of wonky (HSL's pure yellow vs pure blue brightness issue again).
Contrast ratio
WCAG uses relative luminance, which is computed from linearized RGB with the standard coefficients:
export function relativeLuminance(r, g, b) {
const R = sRGBToLinear(r / 255);
const G = sRGBToLinear(g / 255);
const B = sRGBToLinear(b / 255);
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}
export function contrastRatio(rgb1, rgb2) {
const l1 = relativeLuminance(rgb1.r, rgb1.g, rgb1.b);
const l2 = relativeLuminance(rgb2.r, rgb2.g, rgb2.b);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
WCAG thresholds:
- AA: 4.5:1 for normal text, 3:1 for large
- AAA: 7:1 for normal text, 4.5:1 for large
Designers can dial in a color and immediately see whether it passes contrast against white and black backgrounds, which tells them which text color to use.
Series
This is entry #89 in my 100+ public portfolio series.
- π¦ Repo: https://github.com/sen-ltd/color-picker-pro
- π Live: https://sen.ltd/portfolio/color-picker-pro/
- π’ Company: https://sen.ltd/

Top comments (0)