sRGB, OKLCH, and WCAG 2.1 Contrast Calculations in Pure JavaScript
Working with HSL in CSS makes palette generation deceptive: a pure yellow (hsl(60, 100%, 50%)) and a pure blue (hsl(240, 100%, 50%)) report the exact same HSL lightness (50%), yet yellow has a WCAG relative luminance of 0.928 while blue sits at 0.072. Adjusting HSL lightness to pass WCAG AA contrast norms often shifts perceived saturation or muddies the hue.
When building the Sunasty Web Color Picker and the open-source Sunasty Color Picker Chrome Extension, we implemented zero-dependency conversions between sRGB, Björn Ottosson's perceptual Oklab / OKLCH color space, and WCAG 2.1 relative luminance so developers can sample any pixel on screen and immediately compute the nearest AA/AAA-compliant shade at constant hue.
1. sRGB Inverse Companding (Gamma Linearization)
Standard 8-bit RGB channels (0..255) are gamma-encoded using the sRGB transfer function. Before applying linear matrix transformations—whether for CIE XYZ, Oklab, or WCAG relative luminance—each normalized channel c ∈ [0, 1] must be converted to linear light:
export function srgbChannelToLinear(c8bit) {
const c = c8bit / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
export function linearChannelToSrgb(cLinear) {
const clamped = Math.min(1, Math.max(0, cLinear));
const encoded =
clamped <= 0.0031308
? 12.92 * clamped
: 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
return Math.round(encoded * 255);
}
2. Converting Linear sRGB to Oklab and OKLCH
Oklab models human visual perception by transforming linear sRGB into approximate cone responses (l, m, s), applying a cube-root nonlinearity (Math.cbrt), and projecting into orthogonal perceptual coordinates (L, a, b). Polar conversion then yields OKLCH(L, C, H), where L ∈ [0, 1] is perceived lightness, C is chroma, and H ∈ [0, 360) is hue angle in degrees:
export function rgbToOklch(r, g, b) {
const lr = srgbChannelToLinear(r);
const lg = srgbChannelToLinear(g);
const lb = srgbChannelToLinear(b);
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;
const a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
const bAxis = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
const C = Math.sqrt(a * a + bAxis * bAxis);
let H = (Math.atan2(bAxis, a) * 180) / Math.PI;
if (H < 0) H += 360;
return {
L: Number(L.toFixed(4)),
C: Number(C.toFixed(4)),
H: C < 0.0001 ? 0 : Number(H.toFixed(1))
};
}
3. WCAG 2.1 Relative Luminance and Contrast Ratio
Per the WCAG 2.1 specification, relative luminance Y weights the three linear RGB channels according to human photopic luminous efficiency (heavily dominated by green at 71.52%), and the contrast ratio between a lighter luminance L1 and darker luminance L2 spans from 1:1 to 21:1:
export function getRelativeLuminance(r, g, b) {
const R = srgbChannelToLinear(r);
const G = srgbChannelToLinear(g);
const B = srgbChannelToLinear(b);
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}
export function getContrastRatio(rgbA, rgbB) {
const lumA = getRelativeLuminance(rgbA.r, rgbA.g, rgbA.b);
const lumB = getRelativeLuminance(rgbB.r, rgbB.g, rgbB.b);
const brightest = Math.max(lumA, lumB);
const darkest = Math.min(lumA, lumB);
return (brightest + 0.05) / (darkest + 0.05);
}
- WCAG AA Normal Text: minimum
4.5:1(3.0:1for large text ≥ 18pt or ≥ 14pt bold and UI components). - WCAG AAA Normal Text: minimum
7.0:1(4.5:1for large text).
4. Finding the Nearest Accessible Shade via Lightness Search
When a brand color fails WCAG AA against a white (#ffffff) or dark (#09090b) surface, we want to adjust lightness just enough to cross 4.5:1 while keeping the hue angle constant. Because relative luminance is strictly monotonic with respect to perceptual lightness along a fixed hue ray, a 16-step binary search over lightness finds the closest compliant color in microseconds.
5. Related Frontend & Inspection Utilities
Design systems often export color tokens as W3C Design Token JSON files alongside visual regression snapshots. In the Sunasty Developer Tools collection, we pair the color picker with a client-side JSON Formatter and Validator (see our note on formatting and validating JSON locally) and a pixel-level Image Comparison slider and diff view to inspect UI changes before deployment.