Color Contrast Systems
Implement WCAG AAA color contrast across your design system
Color contrast ensures text readability for users with low vision or color blindness. WCAG 2.2 defines minimum ratios, but implementing them systematically across a design system requires careful planning.
WCAG Contrast Requirements
WCAG AA (Minimum):
- Normal text (< 18px): 4.5:1
- Large text (≥ 18px or ≥ 14px bold): 3:1
- UI components: 3:1
WCAG AAA (Enhanced):
- Normal text: 7:1
- Large text: 4.5:1Calculating Contrast Ratio
// utils/contrast.ts
export function getLuminance(r: number, g: number, b: number): number {
const [rs, gs, bs] = [r, g, b].map((c) => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
export function getContrastRatio(rgb1: [number, number, number], rgb2: [number, number, number]): number {
const lum1 = getLuminance(...rgb1);
const lum2 = getLuminance(...rgb2);
const lighter = Math.max(lum1, lum2);
const darker = Math.min(lum1, lum2);
return (lighter + 0.05) / (darker + 0.05);
}
export function meetsWCAG(
foreground: [number, number, number],
background: [number, number, number],
level: 'AA' | 'AAA' = 'AA',
isLargeText = false
): boolean {
const ratio = getContrastRatio(foreground, background);
if (level === 'AAA') {
return isLargeText ? ratio >= 4.5 : ratio >= 7;
}
return isLargeText ? ratio >= 3 : ratio >= 4.5;
}
// Helper to parse hex
export function hexToRgb(hex: string): [number, number, number] {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) throw new Error('Invalid hex color');
return [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16),
];
}Design Tokens with Contrast
// design-system/colors.ts
export const colors = {
// Base colors
white: '#FFFFFF',
black: '#000000',
// Brand colors with accessible variants
primary: {
50: '#E3F2FD',
100: '#BBDEFB',
500: '#2196F3', // Main brand color
600: '#1E88E5', // AA on white
700: '#1976D2', // AAA on white
900: '#0D47A1', // AAA on light backgrounds
},
// Semantic colors - pre-validated
success: {
bg: '#E8F5E9', // Light background
text: '#1B5E20', // AAA contrast with bg
border: '#4CAF50',
},
error: {
bg: '#FFEBEE',
text: '#B71C1C', // AAA contrast
border: '#F44336',
},
warning: {
bg: '#FFF3E0',
text: '#E65100', // AAA contrast
border: '#FF9800',
},
} as const;
// Validate at build time
Object.entries(colors).forEach(([name, value]) => {
if (typeof value === 'object' && 'bg' in value && 'text' in value) {
const ratio = getContrastRatio(
hexToRgb(value.text),
hexToRgb(value.bg)
);
if (ratio < 7) {
console.warn(`${name} does not meet WCAG AAA (${ratio.toFixed(2)}:1)`);
}
}
});React Component for Contrast Checking
// components/ContrastChecker.tsx
export function ContrastChecker({
foreground,
background,
text,
}: Props) {
const fgRgb = hexToRgb(foreground);
const bgRgb = hexToRgb(background);
const ratio = getContrastRatio(fgRgb, bgRgb);
const meetsAA = ratio >= 4.5;
const meetsAAA = ratio >= 7;
return (
<div
style={{
background,
color: foreground,
padding: '20px',
borderRadius: '8px',
}}
>
<p style={{ fontSize: '16px', margin: 0 }}>{text}</p>
<div style={{ marginTop: '10px', fontSize: '14px' }}>
<strong>Contrast Ratio: {ratio.toFixed(2)}:1</strong>
<div>
{meetsAAA && '✓ WCAG AAA'}
{meetsAA && !meetsAAA && '✓ WCAG AA'}
{!meetsAA && '✗ Fails WCAG'}
</div>
</div>
</div>
);
}Auto-Adjusting Text Color
// utils/auto-contrast.ts
export function getContrastingTextColor(
backgroundColor: string,
lightColor = '#FFFFFF',
darkColor = '#000000'
): string {
const bgRgb = hexToRgb(backgroundColor);
const lightRatio = getContrastRatio(hexToRgb(lightColor), bgRgb);
const darkRatio = getContrastRatio(hexToRgb(darkColor), bgRgb);
// Return color with better contrast
return lightRatio > darkRatio ? lightColor : darkColor;
}
// Usage in component
export function Badge({ color, children }: Props) {
const textColor = getContrastingTextColor(color);
return (
<span
style={{
backgroundColor: color,
color: textColor,
padding: '4px 8px',
borderRadius: '4px',
}}
>
{children}
</span>
);
}Finding Accessible Color Pairs
// Generate accessible color scale
export function generateAccessibleScale(
baseColor: string,
steps = 10
): string[] {
const base = hexToRgb(baseColor);
const white = [255, 255, 255] as [number, number, number];
const black = [0, 0, 0] as [number, number, number];
const scale: string[] = [];
for (let i = 0; i < steps; i++) {
const t = i / (steps - 1);
const rgb: [number, number, number] = [
Math.round(base[0] + (white[0] - base[0]) * t),
Math.round(base[1] + (white[1] - base[1]) * t),
Math.round(base[2] + (white[2] - base[2]) * t),
];
// Verify contrast with black text
if (getContrastRatio(black, rgb) >= 4.5) {
scale.push(`#${rgb.map(c => c.toString(16).padStart(2, '0')).join('')}`);
}
}
return scale;
}Color Blindness Simulation
// Simulate different types of color blindness
export function simulateColorBlindness(
rgb: [number, number, number],
type: 'protanopia' | 'deuteranopia' | 'tritanopia'
): [number, number, number] {
const [r, g, b] = rgb;
// Transformation matrices for color blindness
const matrices = {
protanopia: [
[0.567, 0.433, 0],
[0.558, 0.442, 0],
[0, 0.242, 0.758],
],
deuteranopia: [
[0.625, 0.375, 0],
[0.7, 0.3, 0],
[0, 0.3, 0.7],
],
tritanopia: [
[0.95, 0.05, 0],
[0, 0.433, 0.567],
[0, 0.475, 0.525],
],
};
const matrix = matrices[type];
return [
Math.round(r * matrix[0][0] + g * matrix[0][1] + b * matrix[0][2]),
Math.round(r * matrix[1][0] + g * matrix[1][1] + b * matrix[1][2]),
Math.round(r * matrix[2][0] + g * matrix[2][1] + b * matrix[2][2]),
];
}
// Test if colors are distinguishable for color blind users
export function testColorBlindness(color1: string, color2: string): boolean {
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
const types: Array<'protanopia' | 'deuteranopia' | 'tritanopia'> = [
'protanopia',
'deuteranopia',
'tritanopia',
];
return types.every((type) => {
const sim1 = simulateColorBlindness(rgb1, type);
const sim2 = simulateColorBlindness(rgb2, type);
// Check if colors are still distinguishable
const diff = Math.abs(sim1[0] - sim2[0]) +
Math.abs(sim1[1] - sim2[1]) +
Math.abs(sim1[2] - sim2[2]);
return diff > 50; // Threshold for distinguishability
});
}Component Library Integration
// components/Button.tsx
interface ButtonProps {
variant: 'primary' | 'secondary' | 'danger';
children: React.ReactNode;
}
export function Button({ variant, children }: ButtonProps) {
const styles = {
primary: {
bg: colors.primary[700], // Pre-validated AAA
text: colors.white,
hover: colors.primary[900],
},
secondary: {
bg: colors.gray[200],
text: colors.gray[900], // Pre-validated AAA
hover: colors.gray[300],
},
danger: {
bg: colors.error.bg,
text: colors.error.text, // Pre-validated AAA
hover: colors.error.border,
},
};
const style = styles[variant];
return (
<button
style={{
background: style.bg,
color: style.text,
padding: '10px 20px',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = style.hover;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = style.bg;
}}
>
{children}
</button>
);
}Build-Time Validation
// scripts/validate-contrast.ts
import { colors } from './design-system/colors';
function validateColorSystem() {
const errors: string[] = [];
// Test all color combinations
Object.entries(colors).forEach(([key, value]) => {
if (typeof value === 'object' && 'bg' in value && 'text' in value) {
const ratio = getContrastRatio(
hexToRgb(value.text),
hexToRgb(value.bg)
);
if (ratio < 4.5) {
errors.push(`${key}: ${ratio.toFixed(2)}:1 - Fails WCAG AA`);
}
}
});
if (errors.length > 0) {
console.error('Contrast validation failed:');
errors.forEach((error) => console.error(` - ${error}`));
process.exit(1);
}
console.log('✓ All color combinations meet WCAG AA');
}
validateColorSystem();Testing Tools
# Browser extensions
- WAVE (WebAIM)
- axe DevTools
- Lighthouse (built into Chrome)
# Online tools
- WebAIM Contrast Checker
- Contrast Ratio (Lea Verou)
- Colorable
# CLI tools
npm install -D pa11y
pa11y --runner axe https://your-site.comDark Mode Considerations
// Ensure contrast in both themes
export const theme = {
light: {
bg: '#FFFFFF',
text: '#212121', // 16.1:1 - Exceeds AAA
textSecondary: '#616161', // 7:1 - Meets AAA
border: '#E0E0E0',
},
dark: {
bg: '#121212',
text: '#FFFFFF', // 15.8:1 - Exceeds AAA
textSecondary: '#B0B0B0', // 7.4:1 - Meets AAA
border: '#2C2C2C',
},
};
// Validate both themes
Object.entries(theme).forEach(([mode, colors]) => {
const ratio = getContrastRatio(
hexToRgb(colors.text),
hexToRgb(colors.bg)
);
console.log(`${mode} mode contrast: ${ratio.toFixed(2)}:1`);
});Best Practices
- Target AAA when possible (7:1 for normal text)
- Validate at build time to catch regressions
- Test with color blindness simulators
- Larger text = lower requirement (3:1 for large text)
- Don't rely on color alone for information
- Test in different lighting conditions
- Use design tokens with pre-validated colors
- Document contrast ratios in design system
- Automated testing in CI/CD
- Real user testing with vision impairments
Common Pitfalls
❌ Light gray on white: Common mistake
✅ Minimum #757575 on white for AA
❌ Color-only indicators: Red/green states
✅ Add icons or patterns alongside color
❌ Low contrast placeholders: Hard to read
✅ Placeholders need 4.5:1 too
❌ Assuming dark mode works: Different ratios
✅ Validate both light and dark themes
❌ Ignoring focus indicators: Low visibility
✅ Focus rings need 3:1 with background
Color contrast is foundational for accessibility—validate systematically and target WCAG AAA for the best experience!