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:

  1. Gamma Correction: sRGB space is non-linear and needs conversion to linear space first. c <= 0.03928 is a piecewise function - linear approximation for dark colors, 2.4 power for bright colors
  2. Weight Coefficients: 0.2126, 0.7152, 0.0722 are 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 #000000 luminance = 0
  • Pure white #FFFFFF luminance = 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