WCAG Color Contrast: Practical Guide with Real Examples
Understanding WCAG Contrast Requirements
Web Content Accessibility Guidelines (WCAG) define specific contrast ratio requirements to ensure text remains readable for users with visual impairments. These aren't arbitrary numbers—they're based on extensive research about what contrast levels people with different visual abilities can comfortably read.
The Official Standards
WCAG 2.1 AA (Minimum Compliance)
- Normal text (below 18pt or 14pt bold): 4.5:1 minimum contrast ratio
- Large text (18pt+ or 14pt+ bold): 3:1 minimum contrast ratio
- UI components and graphical objects: 3:1 against adjacent colors
WCAG 2.1 AAA (Enhanced Compliance)
- Normal text: 7:1 minimum contrast ratio
- Large text: 4.5:1 minimum contrast ratio
What Do These Numbers Mean?
A contrast ratio of X:1 compares the relative luminance of two colors:
Contrast Ratio = (L1 + 0.05) / (L2 + 0.05)
Where L1 is the lighter color's luminance and L2 is the darker color's luminance (both ranging from 0 to 1).
| Ratio | What It Looks Like | Use Case |
|---|---|---|
| 1:1 | Same color (invisible) | ❌ Never use for text |
| 2:1 | Barely distinguishable | ❌ Fails for all text |
| 3:1 | Noticeable difference | ✅ Large text only (AA) |
| 4.5:1 | Clear difference | ✅ Normal text (AA) |
| 7:1 | Strong difference | ✅ Normal text (AAA) |
| 21:1 | Black on white | ✅ Maximum contrast |
How to Calculate Contrast Ratios
Method 1: Online Tools (Recommended)
The easiest approach is using dedicated tools:
- WebAIM Contrast Checker: https://webaim.org/resources/contrastchecker/
- Coolors Contrast Checker: https://coolors.co/contrast-checker
- Adobe Color Contrast Checker: https://color.adobe.com/create/color-contrast-analyzer
Simply input your foreground and background colors, and these tools instantly tell you the contrast ratio and WCAG compliance level.
Method 2: Browser DevTools
Modern browsers include contrast checking in their DevTools:
- Open DevTools (F12)
- Select an element with text
- Look for the contrast ratio indicator in the Styles panel
- Click it for detailed WCAG compliance information
Method 3: Programmatic Calculation
For custom tools or build-time checking, here's the algorithm:
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null
}
function relativeLuminance(r, g, b) {
const [rs, gs, bs] = [r, g, b].map(c => {
c = c / 255
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
})
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs
}
function contrastRatio(color1, color2) {
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)
const darker = Math.min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
}
Common Contrast Failures and Fixes
Failure 1: Gray Text on White Background
The Problem:
/* ❌ FAILS: 3.13:1 contrast ratio - fails AA for normal text */
.subtle-text {
color: #888888;
background-color: #ffffff;
}
The Fix:
/* ✅ PASSES: 5.74:1 contrast ratio - passes AA */
.subtle-text {
color: #595959;
background-color: #ffffff;
}
Failure 2: Placeholder Text Too Light
The Problem:
/* ❌ FAILS: 2.1:1 - barely visible */
input::placeholder {
color: #c0c0c0;
}
The Fix:
/* ✅ PASSES: 4.6:1 - meets AA */
input::placeholder {
color: #757575;
}
/* Or use opacity for native feel */
input::placeholder {
color: #000000;
opacity: 0.6; /* Equivalent to ~#666 on white */
}
Failure 3: Brand Colors on White
The Problem: Many brand colors fail contrast when placed directly on white:
/* ❌ FAILS: 2.4:1 - your beautiful brand blue */
.brand-button {
background-color: #3b82f6;
color: #ffffff; /* White text on blue */
}
Wait—that actually passes! But what about the reverse?
/* ❌ FAILS: 2.4:1 - brand blue text on white */
.brand-link {
color: #3b82f6;
background-color: #ffffff;
}
The Fix:
/* ✅ PASSES: 5.74:1 - use darker shade of brand */
.brand-link {
color: #1d4ed8; /* brand-700 instead of brand-500 */
background-color: #ffffff;
}
/* Alternative: Add a background */
.brand-link-alt {
color: #3b82f6;
background-color: #eff6ff; /* brand-50 */
padding: 2px 6px;
border-radius: 4px;
}
Failure 4: Disabled State Invisible
The Problem:
/* ❌ FAILS: 1.9:1 - too light to read */
.button-disabled {
color: #d1d5db;
background-color: #ffffff;
}
The Fix:
/* ✅ PASSES: 3.1:1 - still meets large text standard */
.button-disabled {
color: #9ca3af; /* gray-400 */
background-color: #f9fafb; /* gray-50 */
cursor: not-allowed;
}
/* Even better: add a pattern or icon */
.button-disabled::after {
content: " (disabled)";
font-size: 0.85em;
}
Dark Mode: A Special Challenge
Dark mode introduces unique contrast challenges. What works on white often fails on near-black backgrounds.
The Problem with Direct Inversion
/* Light mode - PASSES: 12.6:1 */
.text-light { color: #1f2937; background: #ffffff; }
/* Direct inversion - FAILS: 2.8:1 */
.text-dark { color: #d1d5db; background: #000000; }
The light gray that looked fine on white becomes invisible on black!
The Solution: Use Different Shades
/* Light mode */
@media (prefers-color-scheme: light) {
.text {
color: #1f2937; /* gray-800 */
background-color: #ffffff;
}
}
/* Dark mode - use lighter shade */
@media (prefers-color-scheme: dark) {
.text {
color: #f3f4f6; /* gray-100 */
background-color: #111827; /* gray-900 */
}
}
Dark Mode Contrast Ratios to Target
| Use Case | Light Mode | Dark Mode | Target Ratio |
|---|---|---|---|
| Body text | gray-800 on white | gray-100 on gray-900 | 7:1+ |
| Secondary text | gray-600 on white | gray-300 on gray-900 | 4.5:1+ |
| Subtle text | gray-500 on white | gray-400 on gray-900 | 3:1+ |
| White text on dark | white on gray-800 | white on gray-900 | 4.5:1+ |
Testing Your Color Combinations
Manual Testing Checklist
- Test all text/background combinations
- Test hover, focus, and active states
- Test disabled states
- Test in both light and dark mode
- Test with Windows High Contrast Mode
- Test placeholders and input text
- Test link colors (visited and unvisited)
- Test error, warning, and success messages
Automated Testing Tools
Add these to your workflow:
- axe DevTools (Browser Extension): Runs comprehensive accessibility audits
- Lighthouse (Built into Chrome): Includes accessibility scoring
- Pa11y (CI/CD): Command-line accessibility testing
- jest-axe (Unit Tests): Test contrast in your component tests
// Example with jest-axe
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)
test('button has sufficient contrast', async () => {
const { container } = render(<Button>Click me</Button>)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
Quick Reference: Safe Color Combinations
Here are pre-calculated, WCAG-compliant color pairs:
On White Background (#FFFFFF)
| Text Color | Contrast Ratio | WCAG Level |
|---|---|---|
| #1f2937 (gray-800) | 12.6:1 | AAA |
| #374151 (gray-700) | 8.6:1 | AAA |
| #4b5563 (gray-600) | 5.9:1 | AA |
| #6b7280 (gray-500) | 3.5:1 | Large text only |
| #dc2626 (red-600) | 5.4:1 | AA |
| #ea580c (orange-600) | 4.6:1 | AA |
| #ca8a04 (yellow-600) | 6.1:1 | AA |
| #16a34a (green-600) | 5.1:1 | AA |
| #2563eb (blue-600) | 5.7:1 | AA |
| #7c3aed (violet-600) | 5.9:1 | AA |
On Dark Background (#111827)
| Text Color | Contrast Ratio | WCAG Level |
|---|---|---|
| #f9fafb (gray-50) | 12.6:1 | AAA |
| #f3f4f6 (gray-100) | 10.9:1 | AAA |
| #e5e7eb (gray-200) | 8.4:1 | AAA |
| #d1d5db (gray-300) | 5.7:1 | AA |
| #9ca3af (gray-400) | 3.1:1 | Large text only |
| #fca5a5 (red-300) | 5.6:1 | AA |
| #fdba74 (orange-300) | 5.1:1 | AA |
| #fde047 (yellow-300) | 9.7:1 | AAA |
| #86efac (green-300) | 5.8:1 | AA |
| #93c5fd (blue-300) | 5.4:1 | AA |
Related Tools
- Color Converter - Convert colors to find accessible alternatives
- Tailwind Color Palette - Build accessible color systems with Tailwind