DEV Community

Bastien Sulyan
Bastien Sulyan

Posted on Originally published at bsulyan.github.io

Perceptual Color Systems in JavaScript: OKLCH Conversion and WCAG 2.1 Contrast Auto-Fix

Design systems built on cylindrical hsl(h, s, l) math consistently run into the same perceptual flaw: human vision does not perceive equal lightness across hues. In HSL, pure yellow (hsl(60, 100%, 50%), #FFFF00) and pure blue (hsl(240, 100%, 50%), #0000FF) both report a lightness of 50%. Yet their WCAG 2.1 relative luminances are 0.9278 and 0.0722 respectively—meaning white text is illegible on yellow (1.07:1) while passing AAA on blue (8.59:1).

Below is the mathematical implementation for converting sRGB colors into Björn Ottosson's perceptual OKLCH color space, evaluating WCAG 2.1 luminance contrast, and automatically solving for the nearest accessible foreground lightness—all in zero-dependency JavaScript.

(If you want to drop these functions directly into a Node.js or browser project, the open-source package sunasty-color-math and the online Sunasty Color Picker implement the full pipeline client-side.)

1. Transforming sRGB to Perceptual OKLCH

Converting an 8-bit sRGB triplet $(R, G, B \in [0, 255])$ into oklch(L C h) requires four deterministic steps:

  1. Electro-Optical Transfer Function (EOTF): Undo sRGB gamma compression to obtain linear-light RGB channels in $[0, 1]$.
  2. LMS Cone Response Matrix: Map linear sRGB into approximate human cone responses $(l, m, s)$.
  3. Cube-Root Non-Linearity: Apply $\sqrt[3]{x}$ to model perceptual lightness compression, then project into Oklab $(L, a, b)$.
  4. Polar Coordinate Conversion: Convert Cartesian $(a, b)$ into Chroma $C = \sqrt{a^2 + b^2}$ and Hue angle $h = \text{atan2}(b, a) \cdot \frac{180}{\pi}$.
function srgbChannelToLinear(c) {
  const v = Math.max(0, Math.min(255, c)) / 255;
  return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
}

export function rgbToOklch(r, g, b) {
  const lr = srgbChannelToLinear(r);
  const lg = srgbChannelToLinear(g);
  const lb = srgbChannelToLinear(b);

  const l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb;
  const m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb;
  const s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb;

  const l_ = Math.cbrt(l);
  const m_ = Math.cbrt(m);
  const s_ = Math.cbrt(s);

  const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
  const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
  const bVal = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;

  const C = Math.sqrt(a * a + bVal * bVal);
  let h = (Math.atan2(bVal, a) * 180) / Math.PI;
  if (h < 0) h += 360;

  return {
    L: Number(Math.max(0, Math.min(1, L)).toFixed(2)),
    C: Number(C.toFixed(2)),
    h: C < 0.004 ? 0 : Math.round(h),
  };
}
Enter fullscreen mode Exit fullscreen mode

When you rotate the hue $h$ in oklch(L C h) while holding $L$ constant (for example, L = 0.62), every generated badge or chart series maintains uniform perceived brightness.

2. Computing WCAG 2.1 Contrast Ratios and Automatic AA Lightness Adjustment

Under WCAG 2.1 Success Criterion 1.4.3, normal body text requires a luminance contrast ratio of at least 4.5:1, while large text (18pt+ or 14pt+ bold) and UI graphics (including QR codes and icons) require at least 3.0:1:

$$\text{Contrast Ratio} = \frac{L_{\text{lighter}} + 0.05}{L_{\text{darker}} + 0.05}$$

where relative luminance $Y$ is weighted by the CIE 1931 photopic luminosity function:

$$Y = 0.2126 \cdot R_{\text{lin}} + 0.7152 \cdot G_{\text{lin}} + 0.0722 \cdot B_{\text{lin}}$$

When a designer picks a muted brand accent—such as Slate 400 (#94A3B8) on a white surface (#FFFFFF), which yields 2.56:1—we can automatically compute the closest accessible shade that preserves the original hue and saturation while meeting 4.5:1:

export function relativeLuminance(r, g, b) {
  return (
    0.2126 * srgbChannelToLinear(r) +
    0.7152 * srgbChannelToLinear(g) +
    0.0722 * srgbChannelToLinear(b)
  );
}

export function computeContrastRatio(fgRgb, bgRgb) {
  const lum1 = relativeLuminance(fgRgb.r, fgRgb.g, fgRgb.b);
  const lum2 = relativeLuminance(bgRgb.r, bgRgb.g, bgRgb.b);
  const brightest = Math.max(lum1, lum2);
  const darkest = Math.min(lum1, lum2);
  const rawRatio = (brightest + 0.05) / (darkest + 0.05);

  return {
    ratio: Number(rawRatio.toFixed(2)),
    aaNormal: rawRatio >= 4.5,
    aaLarge: rawRatio >= 3.0,
    aaaNormal: rawRatio >= 7.0,
  };
}
Enter fullscreen mode Exit fullscreen mode

By stepping lightness downward when the background luminance exceeds 0.18 (or upward on dark surfaces), autoFixContrastForAA('#94A3B8', '#FFFFFF', 4.5) converges in under 50 iterations to #596B82 (4.56:1), preserving the cool slate hue (215deg).

3. Practical Verification Across UI Components and Scannable Graphics

Color math does not stop at CSS custom properties; it directly impacts visual regression testing and optical decoding:

  • Interactive Token Inspection: Use the Sunasty Color Picker (or the open-source Chromium MV3 extension) to sample any viewport pixel, inspect its HEX, HSL, OKLCH, and nearest Tailwind CSS token, and run one-click WCAG AA auto-fixes.
  • Visual Regression Auditing: When migrating a legacy HSL palette to perceptual OKLCH tokens or compressing UI assets, verify pixel-level deltas side-by-side in the Sunasty Image Compare split-view inspector.
  • Optical Contrast for QR Matrices: Custom-branded QR codes frequently fail to scan on mobile cameras when designers style the foreground modules with low-contrast pastels. Maintaining at least a 3.0:1 luminance ratio (and testing low-contrast binarization against the Sunasty QR Scanner) ensures reliable decoding across both hardware laser scanners and browser camera feeds.

Top comments (0)