Modern QA2026Color Contrast Verification — tiles
Log inJoin
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([]);
});