31 / 74 · 10 Visual & Accessibility Testing · ARIA Labels and Color Contrast← prev⊞ allnext →☰ Read as one page
6.3Color Contrast Verification
WCAG 2.1 AA requires:
- 4.5:1 contrast ratio for normal text (under 18pt or 14pt bold)
- 3:1 contrast ratio for large text (18pt+ or 14pt+ bold)
- 3:1 contrast ratio for UI components and graphical objects
Automated Contrast Testing
// tests/accessibility/color-contrast.spec.ts
import { test, expect } from '@playwright/test';
test('text elements meet WCAG AA contrast ratios', async ({ page }) => {
await page.goto('/');
const violations = await page.evaluate(() => {
const results: string[] = [];
// Get relative luminance of a color
function luminance(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;
}
// Calculate contrast ratio between two colors
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);
}
// Parse color string to RGB
function parseColor(color: string): [number, number, number] | null {
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (match) return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])];
return null;
}
// Check all text elements
const textElements = document.querySelectorAll(
'p, span, a, h1, h2, h3, h4, h5, h6, li, td, th, label, button'
);
textElements.forEach(el => {
const styles = window.getComputedStyle(el);
const fg = parseColor(styles.color);
const bg = parseColor(styles.backgroundColor);
if (fg && bg && styles.display !== 'none' && styles.visibility !== 'hidden') {
const fgLum = luminance(...fg);
const bgLum = luminance(...bg);
const ratio = contrastRatio(fgLum, bgLum);
const fontSize = parseFloat(styles.fontSize);
const isBold = parseInt(styles.fontWeight) >= 700;
const isLargeText = fontSize >= 24 || (fontSize >= 18.66 && isBold);
const requiredRatio = isLargeText ? 3.0 : 4.5;
if (ratio < requiredRatio) {
const text = el.textContent?.trim().substring(0, 40) || '';
results.push(
`"${text}" has contrast ${ratio.toFixed(2)}:1 ` +
`(needs ${requiredRatio}:1) -- ` +
`fg: rgb(${fg.join(',')}) bg: rgb(${bg.join(',')})`
);
}
}
});
return results;
});
if (violations.length > 0) {
console.error('Color contrast violations:');
violations.forEach(v => console.error(` - ${v}`));
}
expect(violations).toEqual([]);
});