Skip to content
MAIB

How I check contrast in OKLCH

The script that converts the site's color tokens to sRGB luminance and checks them against WCAG contrast thresholds.

4 min read
  • design-system
  • a11y
enpt
On this page

I built the site's color palette in OKLCH. This space makes it easier to adjust lightness across tokens, but it is not the space WCAG uses to calculate contrast.

Whenever I change a color, I run a script that converts the values to sRGB luminance and calculates the ratio for every important pair. I can choose the appearance in OKLCH, then check accessibility with the WCAG formula.

From OKLCH to WCAG contrast#

In OKLCH, the L component represents perceptual lightness. That helps keep a dark palette consistent because moving L produces a more predictable visual change.

WCAG criterion 1.4.3 uses a different measure: relative luminance in linear sRGB. For AA text, the ratio must reach 4.5:1. The L value helps with design, but it cannot directly prove that a pair meets this threshold.

The script follows four steps:

  • OKLCH to OKLab: turn chroma and hue into the a and b axes.
  • OKLab to linear sRGB: apply Ottosson's constants, pass through the LMS components, and arrive at r, g, b.
  • Linear sRGB to luminance: WCAG's weighted sum, 0.2126 r + 0.7152 g + 0.0722 b.
  • Luminance to ratio: (hi + 0.05) / (lo + 0.05), with the larger of the two on top.

Converting to linear sRGB#

The main part of the script is this function:

function oklchToLinear(L, C, H) {
  const a = C * Math.cos(H * DEG);
  const b = C * Math.sin(H * DEG);
 
  const lp = L + 0.3963377774 * a + 0.2158037573 * b;
  const mp = L - 0.1055613458 * a - 0.0638541728 * b;
  const sp = L - 0.0894841775 * a - 1.291485548 * b;
 
  const l = lp * lp * lp;
  const m = mp * mp * mp;
  const s = sp * sp * sp;
 
  return {
    r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
    g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
    b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
  };
}

The coefficients were published by Björn Ottosson, the author of OKLab, and are part of the definition of the space.

The gamma detail#

The r, g, b values returned by the function are already in linear sRGB. That detail changes how luminance should be calculated.

Many implementations begin by decoding each sRGB channel with ((c + 0.055) / 1.055) ^ 2.4. That step is required when the input is gamma-encoded sRGB, such as a #rrggbb value. Here, Ottosson's conversion has already produced linear values. Decoding them again would apply the correction twice and produce an incorrect ratio.

The script therefore uses r, g, b directly in the formula and only applies a clamp to [0, 1] for out-of-gamut values:

function luminance([L, C, H]) {
  const { r, g, b } = oklchToLinear(L, C, H);
  return 0.2126 * clamp01(r) + 0.7152 * clamp01(g) + 0.0722 * clamp01(b);
}

A comment at the top of the file explains this so that a future change does not add the decode back in.

Checking the site's tokens#

The tokens are [L, C, H] arrays that mirror the :root in globals.css. foreground uses [0.92, 0.012, 85], while background uses [0.17, 0.006, 70]. After conversion, this pair reaches 15.10:1, above the 4.5:1 threshold for text.

Before checking the palette, the script runs a self-check with reference values. They include foreground / background at about 15.1 and border / background at about 1.51. If a change breaks the conversion, the script stops before evaluating the tokens.

The configured thresholds are 4.5:1 for text and 3.0:1 for non-text elements such as a focus ring or an active input border. The border token is marked decorative and exempt under SC 1.4.11. The current result is 13 required pairs passing and 1 exempt pair.

The red that failed#

The script found a problem with destructive, the error red. With L set to 0.585, its contrast against destructive-foreground was 4.06:1. The color looked fine on screen but fell below the minimum for AA text.

I lowered L to 0.52 and ran the check again. The ratio increased to 5.35:1. It was a small adjustment that I probably would not have made by looking at the two reds alone.

When I run the check#

The script does not run in every CI build because the tokens change infrequently and usually arrive in a design PR. When I edit the palette, I run node scripts/check-contrast.mjs and review the result alongside the diff. It is a small check that keeps me from judging accessibility by eye alone.

Share