From RGB to WCAG: Understanding Color Contrast Calculation
From RGB to WCAG: Understanding Color Contrast Calculation#
During an accessibility audit, I found several color combinations in our design system that didn’t meet contrast requirements. When the designer asked “How is this contrast ratio actually calculated?”, I realized there’s a complete mathematical model behind what seems like a simple concept.
Relative Luminance: Human Eyes Are Non-Linear#
WCAG contrast calculation is based on Relative Luminance, not simple RGB value differences.
Why? Because human perception of color is non-linear. The same brightness change is more noticeable in dark areas than in bright areas.
The formula for converting RGB to relative luminance:
function getLuminance(r: number, g: number, b: number): number {
// 1. Gamma correction: Convert sRGB to linear RGB
const [rs, gs, bs] = [r, g, b].map(c => {
c = c / 255
// sRGB gamma correction formula
return c <= 0.03928
? c / 12.92
: Math.pow((c + 0.055) / 1.055, 2.4)
})
// 2. Weighted sum: Human eyes are most sensitive to green, least to blue
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs
}
Key points:
- Gamma Correction: sRGB space is non-linear and needs conversion to linear space first.
c <= 0.03928is a piecewise function - linear approximation for dark colors, 2.4 power for bright colors - Weight Coefficients:
0.2126, 0.7152, 0.0722are the weights for R, G, B respectively. Green has the highest weight (71.52%) because human eyes are most sensitive to green
Example:
// Pure red #FF0000
getLuminance(255, 0, 0) // 0.2126
// Pure green #00FF00
getLuminance(0, 255, 0) // 0.7152
// Pure blue #0000FF
getLuminance(0, 0, 255) // 0.0722
Pure green is 3.36x brighter than pure red, and 9.93x brighter than pure blue. That’s why green text on black background is more readable than red or blue.
Contrast Formula: Why (L1 + 0.05) / (L2 + 0.05)#
With relative luminance, contrast calculation is straightforward:
function getContrastRatio(hex1: string, hex2: string): number {
const rgb1 = hexToRgb(hex1)
const rgb2 = hexToRgb(hex2)
const lum1 = getLuminance(rgb1.r, rgb1.g, rgb1.b)
const lum2 = getLuminance(rgb2.r, rgb2.g, rgb2.b)
const brightest = Math.max(lum1, lum2)
const darkest = Math.min(lum1, lum2)
return (brightest + 0.05) / (darkest + 0.05)
}
Why add 0.05?
This handles edge cases. If the darkest color has luminance 0 (pure black), dividing by 0 would cause an error. Adding 0.05 is like giving all colors a base luminance, simulating ambient light in the real world.
Formula derivation:
- Contrast range: 1:1 (same color) to 21:1 (pure black vs pure white)
- Pure black
#000000luminance = 0 - Pure white
#FFFFFFluminance = 1 - Maximum contrast = (1 + 0.05) / (0 + 0.05) = 21
WCAG Levels: AA vs AAA#
WCAG 2.1 defines three accessibility levels:
| Level | Normal Text | Large Text | Description |
|---|---|---|---|
| AA | 4.5:1 | 3:1 | Basic requirement, most scenarios |
| AAA | 7:1 | 4.5:1 | Highest standard, government/healthcare |
Large text definition: At least 18pt (24px) or 14pt (18.66px) bold.
Why lower requirements for large text? Because large text is easier to read, even with slightly lower contrast.
Grade evaluation function:
function getWCAGGrade(ratio: number) {
return {
aa: ratio >= 4.5, // Normal text AA
aaa: ratio >= 7, // Normal text AAA
aaLarge: ratio >= 3, // Large text AA
aaaLarge: ratio >= 4.5 // Large text AAA
}
}
Pitfalls I Encountered#
1. Light Gray Text Trap#
Common design pattern: #999999 text on white background, contrast ratio only 2.85:1, fails AA.
getContrastRatio('#999999', '#FFFFFF') // 2.85:1
Solution: Use #767676 (contrast 4.54:1) or darker.
2. Brand Color Fails AA#
Company brand color #FF6B6B (light red) on white background, contrast 3.01:1, only suitable for large text.
getContrastRatio('#FF6B6B', '#FFFFFF') // 3.01:1
Solution: Use dark text on brand color background, or adjust brand color saturation.
3. Gradient Background Contrast#
On gradient backgrounds, contrast varies at each point. You need to calculate contrast for both the darkest and brightest values in the gradient to ensure compliance throughout.
// Linear gradient: from #FFFFFF to #F0F0F0
const startContrast = getContrastRatio('#333333', '#FFFFFF') // 12.63:1
const endContrast = getContrastRatio('#333333', '#F0F0F0') // 11.78:1
// Minimum contrast is 11.78:1, still passes
Performance: Batch Checking#
When checking an entire design system with dozens of color combinations, use memoization:
// Pre-calculate luminance for all colors
const luminanceCache = new Map<string, number>()
function getCachedLuminance(hex: string): number {
if (luminanceCache.has(hex)) {
return luminanceCache.get(hex)!
}
const rgb = hexToRgb(hex)
const lum = getLuminance(rgb.r, rgb.g, rgb.b)
luminanceCache.set(hex, lum)
return lum
}
// Batch check
function checkColorPairs(pairs: Array<{fg: string, bg: string}>) {
return pairs.map(({ fg, bg }) => {
const lum1 = getCachedLuminance(fg)
const lum2 = getCachedLuminance(bg)
const ratio = (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05)
return {
fg,
bg,
ratio,
grade: getWCAGGrade(ratio)
}
})
}
Tool Recommendation#
Based on these principles, I built: Color Contrast Checker
Features:
- Real-time contrast calculation
- WCAG AA/AAA level evaluation
- Support for normal and large text
- Visual preview
Related: Color Picker | Palette Generator