Color Contrast in Practice
Hitting 4.5:1 for text, 3:1 for UI, testing with tools, and fixing common failures.
AccessibilityBeginner
Why
Low-contrast text (gray-400 on white) is invisible to low-vision users and fails WCAG 1.4.3. It's the most common a11y failure and the cheapest to fix — pick a darker shade from your scale.
When
Check contrast on every text/bg pairing and every UI boundary (button vs background, icon vs bg). Do it during design, not after — fixing in Figma is free, fixing in code is a PR.
How
1. Targets — normal text <18px: 4.5:1, large text ≥18px or ≥14px bold: 3:1, UI components (icons, borders): 3:1, non-text (logos): exempt
2. Tool — axe-core in CI catches rendered pairs; Polypane/Contrast in design
3. Fix by darkening — move text from gray-500 to gray-700, usually +2 steps on the scale
4. Don't lighten bg — lightening the background to hit ratio often breaks the design; darken text instead
5. Dark mode — recheck; a pairing that passes in light can fail in dark (gray-500 on gray-900)
6. Links — distinguish by more than color (underline), and the link vs body contrast must also pass
7. Disabled — exempt from contrast, but make sure it looks disabled (opacity 0.5 + cursor not-allowed)
Example
// contrast.ts — check a pairing at build time
function ratio(fg: string, bg: string): number {
const l1 = luminance(hexToRgb(fg)), l2 = luminance(hexToRgb(bg));
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// gray-500 (#6b7280) on white = 4.6:1 ✓ (borderline — use gray-600 for body)
// gray-400 (#9ca3af) on white = 2.9:1 ✗ (fails 4.5:1 — use only for large text or icons)
/* Fix: darken the text, not lighten the bg */
.muted-bad { color: var(--color-gray-400); } /* 2.9:1 ✗ */
.muted-good { color: var(--color-gray-600); } /* 7.0:1 ✓ */
/* Links — underline + sufficient contrast vs body text */
a { color: var(--color-action-primary); text-decoration: underline; }
/* blue-600 on white = 4.6:1 ✓ vs gray-900 body = 4.5:1 ✓ */
/* Disabled — exempt, but visually disabled */
button:disabled { opacity: 0.5; cursor: not-allowed; }
Common Problems
- ⚠gray-400 muted text on white — 2.9:1, fails 4.5:1, the most common a11y bug
- ⚠Link distinguished only by color — colorblind users can't tell it's a link, needs underline
- ⚠Light-mode pairing not rechecked in dark — passes in light, fails in dark
Best Practices
- ✓Run axe-core in CI — catches every rendered contrast failure automatically on every PR
- ✓Fix by darkening text (+2 scale steps), not lightening bg — preserves the design
- ✓Recheck every pairing in dark mode — a 4.6:1 light pairing can drop to 3:1 in dark