Contrast ratio turns a familiar UI question—“Can people read this text?”—into a number. It compares the relative luminance, or perceived brightness, of two colors so that text and its background can be checked against WCAG accessibility thresholds rather than judged by eye. section 7.4 section 7.4.1
Calculate it from the lighter luminance L1 and darker luminance L2: CR = (L1 + 0.05) / (L2 + 0.05). Luminance runs from 0 for perceived black to 1 for perceived white, so the result runs from 1:1 for identical brightness to 21:1 for black and white. Always place the higher luminance first; swapping foreground and background does not change the result. section 7.4.2 section 7.4.3
Use the threshold that matches the text treatment:
- Normal text below 18px needs at least 4.5:1 for AA or 7:1 for AAA. section 7.4.1
- Large text—18px or larger, or bold at 14px or larger—needs at least 3:1 for AA or 4.5:1 for AAA. section 7.4.1
- A medium gray, hsl(0 0% 46%), on white reaches about 4.54:1: enough for normal-text AA, but not AAA. A darker hsl(0 0% 35%) reaches 7:1. section 7.4.1
Why hue alone is not enough
Pure blue (#0000FF) has relative luminance 0.0722. Against white (L = 1), its ratio is 8.59:1, exceeding AAA. Against pure red (L = 0.2126), the ratio is only 2.15:1, failing AA. The same blue can therefore be accessible or inaccessible depending on its paired color. section 7.4.3
Relative luminance is not a simple average of RGB channels. Convert RGB values to normalized values, gamma-correct them into linear light, then apply weighted coefficients: 0.2126 for red, 0.7152 for green, and 0.0722 for blue. Those weights reflect differing visual sensitivity across colors. section 7.4.3 section 7.4.4
Listing 7.10 JavaScript code to calculate a contrast ratio
function hexToRgb(hex) { #1
hex = hex.replace(/^#/, ""); #2
if (hex.length === 3) { #3
hex = hex.split("").map(ch => ch + ch).join(""); #3
} #3
const num = parseInt(hex, 16);
return {
r: (num >> 16) & 255, #4
g: (num >> 8) & 255, #4
b: num & 255 #4
};
}
function relativeLuminance(r, g, b) { #5
let R_n = r / 255, G_n = g / 255, B_n = b / 255; #6
const toLinear = (c) => #7
c <= 0.04045 ? #7
c / 12.92 : #7
((c + 0.055) / 1.055) ** 2.4; #7
const R_l = toLinear(R_n); #7
const G_l = toLinear(G_n); #7
const B_l = toLinear(B_n); #7
return 0.2126 * R_l + 0.7152 * G_l + 0.0722 * B_l; #8
}
function getContrastRatio(color1, color2) { #9
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
const L1 = relativeLuminance(rgb1.r, rgb1.g, rgb1.b);
const L2 = relativeLuminance(rgb2.r, rgb2.g, rgb2.b);
const lighter = Math.max(L1, L2); #10
const darker = Math.min(L1, L2); #10
const ratio = (lighter + 0.05) / (darker + 0.05); #11
return ratio;
}
function checkCompliance(cr, isLargeText = false) { #12
const thresholds = {
aa: { normal: 4.5, large: 3.0 }, #13
aaa: { normal: 7.0, large: 4.5 } #13
};
const textType = isLargeText ? 'large' : 'normal';
return {
aa: cr >= thresholds.aa[textType], #14
aaa: cr >= thresholds.aaa[textType] #15
};
}
Use the calculator functions to convert 3- or 6-digit hex colors, derive relative luminance, compute the ratio, and check AA and AAA compliance. This makes contrast checking practical for values returned by a color input. section 7.4.4
Contrast checks also matter when a light/dark-mode toggle changes the UI. Do not treat dark mode as a simple inversion: preserve readable text, separation between backgrounds, and the prominence of accents by maintaining or exceeding the required ratios. section 7.5
Practical takeaway: measure the actual foreground/background pair, use the higher luminance in the numerator, and evaluate the resulting number against the threshold for that text size and weight. section 7.4.1 section 7.4.2


