Color Contrast and Accessibility
The Problem
Color contrast is the number one accessibility violation on the web. WebAIM's annual analysis consistently finds that over 80% of homepages have low-contrast text. AI agents frequently generate designs with light gray text on white backgrounds, placeholder text that fails contrast requirements, decorative color choices that prioritize aesthetics over readability, and interactive elements that are indistinguishable from surrounding content. The result is text that is difficult or impossible to read for users with low vision, color blindness, or in challenging viewing conditions (bright sunlight, dim screens).
The Solution
WCAG (Web Content Accessibility Guidelines) defines minimum contrast ratios between foreground and background colors. Meeting these ratios ensures text is readable for the widest range of users, including those with visual impairments.
WCAG Contrast Requirements
| Level | Normal text (< 18pt / < 14pt bold) | Large text (≥ 18pt / ≥ 14pt bold) | UI components |
|---|---|---|---|
| AA | 4.5:1 | 3:1 | 3:1 |
| AAA | 7:1 | 4.5:1 | — |
"Large text" is defined as 18pt (24px) or 14pt (18.67px) bold and above.
Code Examples
Safe Color Combinations
/* PASS AA — dark text on light background */
.text-on-light {
color: oklch(25% 0.02 264); /* ~#1a1a2e */
background: oklch(98% 0.005 264); /* ~#f8f8fc */
/* Contrast ratio: ~15:1 ✓ */
}
/* PASS AA — light text on dark background */
.text-on-dark {
color: oklch(90% 0.01 264); /* ~#e0e0f0 */
background: oklch(18% 0.015 264); /* ~#1e1e30 */
/* Contrast ratio: ~11:1 ✓ */
}
/* FAIL AA — light gray on white */
.text-low-contrast {
color: oklch(70% 0 0); /* ~#a0a0a0 */
background: oklch(100% 0 0); /* white */
/* Contrast ratio: ~2.6:1 ✗ */
}
Accessible Brand Colors
:root {
/* Brand blue — test against both light and dark backgrounds */
--brand: oklch(45% 0.2 264);
/* ✓ On white (contrast ~7:1) */
--brand-on-light: oklch(45% 0.2 264);
/* ✓ On dark bg (contrast ~5:1) - lighter variant needed */
--brand-on-dark: oklch(72% 0.15 264);
}
/* Apply contextually */
.light-section a {
color: var(--brand-on-light);
}
.dark-section a {
color: var(--brand-on-dark);
}
Accessible Placeholder Text
/* WRONG: Default placeholder is typically too light */
input::placeholder {
color: oklch(75% 0 0); /* ~#b0b0b0 — fails 4.5:1 on white */
}
/* CORRECT: Darker placeholder that passes contrast */
input::placeholder {
color: oklch(48% 0 0); /* ~#6b6b6b — passes 4.5:1 on white */
}
/* Always provide visible labels — don't rely on placeholder as label */
Accessible Disabled States
/* Disabled elements are exempt from WCAG contrast requirements,
but they should still be distinguishable from the background */
.button:disabled {
color: oklch(60% 0 0);
background: oklch(90% 0 0);
cursor: not-allowed;
/* Contrast ~2.5:1 — enough to see it exists, clearly different from active buttons */
}
/* But NEVER use low contrast for text users need to read */
Focus Indicators with Sufficient Contrast
/* Focus ring must have 3:1 contrast against adjacent colors */
:focus-visible {
outline: 2px solid oklch(45% 0.2 264);
outline-offset: 2px;
/* The 2px offset creates a gap, so contrast is measured against the background */
}
/* High-contrast focus ring for dark backgrounds */
.dark-section :focus-visible {
outline: 2px solid oklch(80% 0.15 264);
outline-offset: 2px;
}
Link Contrast
/* Links in body text need 3:1 contrast against surrounding text (WCAG 1.4.1)
OR a non-color visual indicator (underline) */
/* Option 1: Underlined links (recommended — color alone is not enough) */
a {
color: oklch(45% 0.2 264);
text-decoration: underline;
}
/* Option 2: If removing underline, ensure 3:1 contrast with body text
AND add non-color indicator on hover/focus */
a {
color: oklch(45% 0.2 264); /* Must be 3:1 against body text color */
text-decoration: none;
}
a:hover,
a:focus {
text-decoration: underline; /* Non-color indicator */
}
Color Should Not Be the Only Indicator
/* WRONG: Only color differentiates error state */
.input-error {
border-color: red;
}
/* CORRECT: Color plus additional visual indicator */
.input-error {
border-color: oklch(55% 0.22 25);
border-width: 2px; /* Thicker border */
box-shadow: 0 0 0 1px oklch(55% 0.22 25); /* Additional visual cue */
}
<!-- Also include text indication -->
<input class="input-error" aria-describedby="error-name" />
<p id="error-name" class="error-message">Name is required</p>
Testing Contrast with OKLCH
Using OKLCH lightness as a rough contrast predictor:
:root {
/* Rule of thumb: ~45-50 OKLCH lightness units between bg and text
roughly corresponds to WCAG AA 4.5:1 contrast */
--bg-light: oklch(97% 0.005 264); /* L: 97% */
--text-on-light: oklch(25% 0.02 264); /* L: 25% — delta: 72% ✓ */
--bg-dark: oklch(15% 0.01 264); /* L: 15% */
--text-on-dark: oklch(90% 0.01 264); /* L: 90% — delta: 75% ✓ */
/* Muted text needs extra care */
--text-muted-light: oklch(45% 0.02 264); /* L: 45% — delta from bg: 52% ✓ */
--text-muted-dark: oklch(65% 0.01 264); /* L: 65% — delta from bg: 50% ✓ */
}
Note: OKLCH lightness delta is an approximation, not a substitute for actual contrast ratio testing. Always verify with a contrast checker tool.
System-Level High Contrast Support
/* Respect Windows High Contrast / forced-colors mode */
@media (forced-colors: active) {
.button {
border: 2px solid ButtonText;
/* Browser enforces system colors — don't fight it */
}
.icon {
fill: ButtonText; /* Use system color keywords */
}
}
Common AI Mistakes
- Using light gray text (
#999,#aaa,#bbb) on white backgrounds — these all fail WCAG AA (4.5:1) - Setting placeholder text to a light gray that fails contrast, then using the placeholder as the only label
- Generating colored buttons where the text-on-button contrast is insufficient (e.g., white text on a light yellow button)
- Using color as the only means of conveying information — error states shown only in red, links distinguished only by color
- Not testing contrast of interactive states: hover, focus, and active colors must also meet contrast ratios
- Applying
opacityto text for visual hierarchy instead of using lower-contrast colors — opacity reduces contrast unpredictably depending on the background - Assuming "dark mode = accessible" — dark mode needs its own contrast validation, and many dark themes fail contrast requirements
- Using
color: inheritorcurrentColorwithout verifying the inherited value provides sufficient contrast in every context - Not considering non-text contrast requirements (3:1) for borders, icons, and interactive element boundaries
- Ignoring that WCAG contrast ratios apply to the actual rendered colors, including transparency — a semi-transparent text layer on a varying background may pass in some areas and fail in others
When to Use
Contrast checking should happen for every text and UI element in the design:
- Body text: Must meet 4.5:1 against its background (AA) or 7:1 (AAA)
- Large headings (24px+ or 18.67px+ bold): Must meet 3:1 (AA) or 4.5:1 (AAA)
- Interactive controls: Borders, icons, and focus indicators must meet 3:1 against adjacent colors
- Links in text: Must have 3:1 contrast against surrounding body text, or use a non-color indicator like underline
- Form labels and help text: Must meet standard text contrast requirements
- Placeholder text: Must meet 4.5:1 if it conveys required information (better: always use visible labels)
Recommended testing tools
- WebAIM Contrast Checker — quick manual check
- OKLCH Color Picker — visual contrast preview with OKLCH values
- Chrome DevTools — element inspection shows contrast ratio for text
- Lighthouse — automated audit flags contrast failures
- Colour Contrast Analyser (CCA) — desktop app with eyedropper
References
- MDN: Color contrast — Accessibility
- WebAIM: Contrast and Color Accessibility
- WCAG 2.2: Success Criterion 1.4.3 Contrast (Minimum)
- WCAG 2.2: Success Criterion 1.4.11 Non-text Contrast
- Color Contrast Accessibility: Complete WCAG 2025 Guide — AllAccessible
- 3 color contrast mistakes designers still make — UX Collective