Skip to content
Back to Design Hub

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

FAQ