Master WCAG contrast ratios, understand when each threshold applies, implement contrast-compliant color palettes, and automate contrast verification in design and CI pipelines.
Master WCAG contrast ratios, understand when each threshold applies, implement contrast-compliant color palettes, and automate contrast verification in design and CI pipelines.
WCAG 1.4.3 (Contrast Minimum, AA) specifies different thresholds for different text sizes and UI element types:
4.5:1 applies to: normal text (under 18pt regular or under 14pt bold), which covers most body text, form labels, nav items, and button labels at typical sizes.
3:1 applies to: large text (18pt/24px or larger in regular weight, or 14pt/18.67px or larger in bold), UI component boundaries (button outlines, checkbox borders, input borders), and graphical objects needed to understand content (icons, charts).
Exceptions: text in logos, purely decorative text, inactive UI components (disabled states), and text that is part of a picture are exempt from contrast requirements.
1/* Normal text (under 18pt/24px): needs 4.5:1 */
2body { font-size: 1rem; color: #374151; background: #fff; }
3/* #374151 on #fff = 7.0:1 — passes */
4
5/* Large text (24px+): only needs 3:1 */
6h1 { font-size: 2rem; color: #6B7280; background: #fff; }
7/* #6B7280 on #fff = 4.6:1 — passes (only 3:1 required) */
8
9/* UI component boundary: needs 3:1 */
10input { border: 2px solid #D1D5DB; background: #fff; }
11/* #D1D5DB on #fff = 1.6:1 — FAILS */
12input { border: 2px solid #6B7280; background: #fff; }
13/* #6B7280 on #fff = 4.6:1 — passes */Contrast ratio is calculated from relative luminance — the perceived brightness of a color relative to white. You do not need to memorize the formula, but understanding the concept helps you make good color choices intuitively.
Relative luminance ranges from 0 (pure black) to 1 (pure white). The contrast ratio between two colors is: (lighter + 0.05) / (darker + 0.05). Maximum contrast is 21:1 (black on white). WCAG AA requires at least 4.5:1 for normal text.
Practical implications:
Use the free Contrast Ratio tool at contrast-ratio.com or the built-in checker in Figma to test before finalizing any color pairing. Never eyeball contrast — the human eye is a poor judge of ratios below 4.5:1.
Design your color palette with contrast compliance built in from the start, rather than retrofitting after:
1/* OKLCH color space enables predictable contrast across hues */
2/* L (lightness 0-1) directly predicts contrast performance */
3:root {
4 /* Text colors: L < 0.40 ensures 4.5:1 on white */
5 --color-text-primary: oklch(0.28 0 0); /* #111827 ≈ 16.75:1 on white */
6 --color-text-secondary: oklch(0.43 0 0); /* #6B7280 ≈ 4.6:1 on white */
7
8 /* Surface colors: L > 0.93 ensures white text at 4.5:1 is impractical */
9 /* Use dark text on light surfaces */
10 --color-surface-primary: oklch(1 0 0); /* pure white */
11 --color-surface-secondary: oklch(0.97 0 0); /* #F9FAFB */
12
13 /* Brand interactive colors: test each pairing explicitly */
14 --color-brand-blue: oklch(0.47 0.18 262); /* #2563EB — 5.9:1 on white, white text 3.6:1 (use large) */
15
16 /* Status colors — always combine with an icon or label for non-color users */
17 --color-status-error: oklch(0.45 0.22 29); /* #DC2626 — 5.9:1 on white */
18 --color-status-success: oklch(0.43 0.15 145); /* #16A34A — 4.9:1 on white */
19 --color-status-warning: oklch(0.52 0.18 65); /* #D97706 — WARNING: only 2.8:1 on white */
20 /* ^ Use warning color for icons/borders only, not text on white */
21}Warning yellow is a common failure point — it almost never passes 4.5:1 on white. Use warning yellow for background fills with dark text on top, or for icons (3:1 threshold), not for text labels.
Three common misapplications of contrast rules:
Placeholder text: The WCAG exemption for inactive UI components does NOT exempt placeholder text from contrast requirements. Placeholder text conveys information (what to type) and is therefore subject to 4.5:1. Many design systems use low-contrast placeholder text and are non-compliant. Fix:
1/* Placeholder must meet 4.5:1 contrast */
2.input::placeholder {
3 color: #6B7280; /* 4.6:1 on white — barely passes */
4 /* NOT: #9CA3AF (2.9:1 — common failure) */
5}Disabled states: Disabled interactive elements (disabled buttons, read-only inputs) ARE exempt from contrast requirements — WCAG explicitly carves out 'inactive user interface components'. This exemption only applies when the element is truly non-functional, not merely visually subdued for design reasons.
Gradients: Text over a gradient background must meet contrast at the worst point of the gradient (where contrast is lowest). Check the midpoint and both endpoints. Darkening overlay gradients (linear-gradient from transparent to black) are a reliable solution for text over photos.
1/* Text over photo: gradient ensures contrast everywhere */
2.hero {
3 background-image:
4 linear-gradient(to bottom, transparent 40%, rgba(0,0,0,0.7) 100%),
5 url('/hero.jpg');
6}
7.hero-text {
8 color: white; /* White on rgba(0,0,0,0.7) = 8.6:1 — passes */
9}Manual contrast checking does not scale. Automate it in your CI pipeline for design tokens:
1// scripts/verify-contrast.mjs
2// Run as part of design token pipeline CI
3import { colord } from 'colord';
4
5function getContrastRatio(color1, color2) {
6 const lum1 = colord(color1).toLuminance();
7 const lum2 = colord(color2).toLuminance();
8 const lighter = Math.max(lum1, lum2);
9 const darker = Math.min(lum1, lum2);
10 return (lighter + 0.05) / (darker + 0.05);
11}
12
13const REQUIRED_PAIRS = [
14 // [foreground, background, required ratio, label]
15 ['#374151', '#ffffff', 4.5, 'Body text on white'],
16 ['#6B7280', '#ffffff', 4.5, 'Secondary text on white'],
17 ['#ffffff', '#2563EB', 4.5, 'White on brand blue (button)'],
18 ['#6B7280', '#ffffff', 3.0, 'Input border on white'],
19 ['#6B7280', '#F9FAFB', 4.5, 'Placeholder on input bg'],
20 ['#DC2626', '#ffffff', 4.5, 'Error text on white'],
21];
22
23let failed = 0;
24for (const [fg, bg, required, label] of REQUIRED_PAIRS) {
25 const ratio = getContrastRatio(fg, bg);
26 const status = ratio >= required ? 'PASS' : 'FAIL';
27 if (status === 'FAIL') failed++;
28 console.log(`${status} ${label}: ${ratio.toFixed(2)}:1 (required ${required}:1)`);
29}
30
31if (failed > 0) process.exit(1);
32console.log('All contrast checks passed.');Run this script in CI whenever token values change. Post results as a PR comment using a GitHub Actions bot.
Run your current color palette through a contrast checker. Test every text color against every background it appears on. Document which pairings fail and prioritize fixing any that appear in high-frequency UI patterns like body text and form labels.
Your progress is saved in this browser, on this device only.