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.
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.