Understand WCAG contrast ratios, implement accessible color systems, and use automated tools to catch accessibility issues before they ship.
About 300 million people worldwide have color vision deficiency, and millions more have low vision. Poor color contrast makes your app unusable for a significant portion of your users — and in many regions, it creates legal liability. Here's how to get it right.
WCAG (Web Content Accessibility Guidelines) is the international standard for web accessibility, published by the W3C. WCAG 2.1 is currently the most widely adopted version (WCAG 2.2 is current; WCAG 3.0 is in draft).
Conformance levels:
Most accessibility laws (ADA, EN 301 549, AODA) require AA conformance.
WCAG defines contrast as the ratio of relative luminance between foreground and background colors. The scale runs from 1:1 (no contrast, same color) to 21:1 (black on white).
WCAG 2.x requirements:
| Content type | Level AA | Level AAA |
|---|---|---|
| Normal text (< 18pt or < 14pt bold) | 4.5:1 | 7:1 |
| Large text (≥ 18pt or ≥ 14pt bold) | 3:1 | 4.5:1 |
| UI components, icons, charts | 3:1 | N/A |
| Decorative elements | None | None |
Note: Placeholder text, disabled states, and logos are exempt.
The formula uses relative luminance (L):
Contrast ratio = (L1 + 0.05) / (L2 + 0.05)
Where L1 is the lighter color's luminance and L2 is the darker color's luminance.
Relative luminance is calculated from linearized RGB values. This is complex to do manually — use a tool like HeoLab's Contrast Ratio Checker or browser DevTools.
These color combinations fail WCAG AA despite appearing fine to people with full color vision:
| Foreground | Background | Ratio | Status |
|---|---|---|---|
| #767676 | #ffffff | 4.54:1 | ✅ Pass (barely) |
| #888888 | #ffffff | 3.54:1 | ❌ Fail |
| #0070f3 | #ffffff | 3.94:1 | ❌ Fail (popular blue!) |
| #0060df | #ffffff | 4.60:1 | ✅ Pass |
| #2563eb | #ffffff | 4.67:1 | ✅ Pass |
Many design systems ship with non-compliant default colors for links and buttons. Always verify.
Design your palette with a fixed lightness value that guarantees AA on white:
:root {
/* Primary brand — lightness ≤ 46% guarantees 4.5:1 on white */
--brand: hsl(220, 90%, 44%); /* L=44%, ratio ~5.2:1 ✅ */
--brand-light: hsl(220, 90%, 55%); /* L=55%, ratio ~2.8:1 ❌ use for accents only */
/* Text */
--text-primary: hsl(0, 0%, 9%); /* ~19:1 on white ✅ */
--text-secondary: hsl(0, 0%, 38%); /* ~4.6:1 on white ✅ */
--text-muted: hsl(0, 0%, 56%); /* ~2.7:1 on white ❌ decorative only */
}
A color pair that passes on light background might fail on dark. Always check both modes.
.dark {
/* Lighter shade needed for same contrast on dark bg */
--brand: hsl(220, 90%, 65%); /* lighter for dark bg */
--text-primary: hsl(0, 0%, 95%);
}
WCAG 1.4.1 requires that color is not the only visual means of conveying information:
<!-- Bad: error communicated only by red color -->
<input style="border-color: red">
<!-- Good: error communicated by color + icon + text -->
<input style="border-color: red" aria-invalid="true">
<span role="alert">
<svg><!-- error icon --></svg>
Email is required
</span>
This also helps users with color blindness — they can't distinguish red from green, but they can read text.
npm install axe-core
Use with Playwright or Cypress for CI/CD integration:
import { checkA11y } from 'axe-playwright';
test('has no contrast violations', async ({ page }) => {
await page.goto('http://localhost:3000');
await checkA11y(page);
});
Chrome DevTools (Inspect → Styles → color picker) shows the contrast ratio and whether it passes. The CSS Overview panel audits the entire page.