DEV Community

SEN LLC
SEN LLC

Posted on

An Advanced Color Picker With OKLCh, LCh, LAB, and Color Harmony

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

Screenshot

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_,
  };
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode
  • 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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)