← Back to Writing
4 min read

Color Contrast in Practice


WCAG requires a 4.5:1 contrast ratio for normal text and 3:1 for large text. These numbers are widely known. What's less discussed is how to build a token system that enforces them across light mode, dark mode, hover states, and disabled states without manual checking.

The Problem with Hardcoded Colors

If your button uses color: #666 on background: #fff, that's a 5.7:1 ratio — passing. But if that same button appears on a #f5f5f5 surface, the ratio drops to 4.1:1 — failing. Hardcoded colors require you to manually verify every combination.

Token-Based Contrast Guarantees

The solution is to define your palette in terms of contrast relationships, not absolute values:

When these relationships are defined once and enforced by your token system, every component that uses them inherits the guarantee. Dark mode works automatically because you're redefining the tokens, not the colors.

Checking Your Work

function contrastRatio(l1: number, l2: number): number {
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);
  return (lighter + 0.05) / (darker + 0.05);
}

Tools like Colour Contrast Analyser (free, desktop) let you pick colors directly from the screen. Run it on your muted text against every background it appears on, not just the primary background.

Don't Forget States

Focus indicators, hover states, error messages, and placeholder text all need to meet contrast requirements. Placeholder text is the most commonly missed — browsers default it to a gray that frequently fails.