QA Engineer Skills 2026QA-2026ARIA Labels and Color Contrast

ARIA Labels and Color Contrast

ARIA Best Practices

ARIA (Accessible Rich Internet Applications) supplements HTML semantics. The first rule of ARIA is: if you can use a native HTML element, do not use ARIA. Native elements come with built-in keyboard handling, focus management, and screen reader semantics.

<!-- BAD: div with ARIA role mimicking a button -->
<div role="button" tabindex="0" aria-label="Submit" onclick="submit()">
    Submit
</div>
<!-- Problems: no Enter/Space handling, no form submission, no disabled state -->

<!-- GOOD: native button element -- keyboard, screen reader, and click all work -->
<button type="submit">Submit</button>

<!-- WHEN ARIA IS NECESSARY: custom components with no native equivalent -->
<div role="tablist" aria-label="Product details">
    <button role="tab" aria-selected="true" aria-controls="panel-desc" id="tab-desc">
        Description
    </button>
    <button role="tab" aria-selected="false" aria-controls="panel-specs" id="tab-specs">
        Specifications
    </button>
</div>
<div role="tabpanel" id="panel-desc" aria-labelledby="tab-desc">
    <!-- Description content -->
</div>
<div role="tabpanel" id="panel-specs" aria-labelledby="tab-specs" hidden>
    <!-- Specs content -->
</div>

Automated ARIA Testing

Testing Alt Text Quality

// tests/accessibility/aria-validation.spec.ts
import { test, expect } from '@playwright/test';

test('all images have alt text', async ({ page }) => {
    await page.goto('/products');

    const images = await page.locator('img').all();
    const violations: string[] = [];

    for (const img of images) {
        const alt = await img.getAttribute('alt');
        const src = await img.getAttribute('src');
        const role = await img.getAttribute('role');

        // Decorative images should have alt="" and role="presentation"
        // Content images must have descriptive alt text
        if (role === 'presentation' || role === 'none') {
            // Decorative: alt must be empty string (not missing)
            if (alt !== '') {
                violations.push(`Decorative image ${src} should have alt="", got alt="${alt}"`);
            }
        } else {
            // Content image: must have meaningful alt
            if (!alt || alt.trim().length === 0) {
                violations.push(`Image ${src} has no alt text`);
            } else if (alt.match(/^(image|photo|picture|img|icon)\b/i)) {
                violations.push(`Image ${src} has non-descriptive alt: "${alt}"`);
            } else if (src && alt === src.split('/').pop()) {
                violations.push(`Image ${src} uses filename as alt: "${alt}"`);
            }
        }
    }

    expect(violations).toEqual([]);
});

Testing Focus Indicators

test('interactive elements have visible focus indicators', async ({ page }) => {
    await page.goto('/');

    // Tab through all focusable elements
    const focusableSelector = 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])';
    const focusableCount = await page.locator(focusableSelector).count();

    const violations: string[] = [];

    for (let i = 0; i < Math.min(focusableCount, 50); i++) {
        await page.keyboard.press('Tab');

        // Get the currently focused element
        const focused = page.locator(':focus');
        const isVisible = await focused.isVisible();

        if (isVisible) {
            // Check that focus indicator is visible
            const outlineStyle = await focused.evaluate((el) => {
                const styles = window.getComputedStyle(el);
                return {
                    outline: styles.outline,
                    outlineWidth: styles.outlineWidth,
                    outlineColor: styles.outlineColor,
                    boxShadow: styles.boxShadow,
                    border: styles.border,
                    tag: el.tagName,
                    text: el.textContent?.trim().substring(0, 30),
                };
            });

            // Focus must be visible via outline, box-shadow, or border change
            const hasVisibleFocus =
                outlineStyle.outlineWidth !== '0px' ||
                outlineStyle.boxShadow !== 'none' ||
                outlineStyle.outline !== 'none';

            if (!hasVisibleFocus) {
                violations.push(
                    `${outlineStyle.tag} "${outlineStyle.text}" has no visible focus indicator`
                );
            }
        }
    }

    expect(violations).toEqual([]);
});

Color 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([]);
});

Common ARIA Mistakes

Mistake Why It Is Wrong Correct Approach
<div role="button"> Missing keyboard handling Use <button>
aria-label on <div> without role aria-label has no effect on generic elements Add a role or use a semantic element
aria-hidden="true" on focusable element Removes from accessibility tree but still focusable Also add tabindex="-1"
Duplicate aria-labelledby targets Confusing screen reader output Each label target should be unique
role="presentation" on interactive element Removes semantics from clickable element Never suppress semantics on interactive elements
Using ARIA to fix a broken HTML structure ARIA compensates but does not truly fix issues Fix the HTML structure first

Testing for these mistakes can be automated:

test('no ARIA anti-patterns', async ({ page }) => {
    await page.goto('/');

    const antiPatterns = await page.evaluate(() => {
        const issues: string[] = [];

        // Check for divs with role="button" (should be <button>)
        document.querySelectorAll('[role="button"]:not(button)').forEach(el => {
            issues.push(`Non-button element with role="button": ${el.outerHTML.substring(0, 100)}`);
        });

        // Check for aria-hidden on focusable elements
        document.querySelectorAll('[aria-hidden="true"]').forEach(el => {
            const tabindex = el.getAttribute('tabindex');
            if (tabindex !== '-1' && (el as HTMLElement).tabIndex >= 0) {
                issues.push(`Focusable element with aria-hidden: ${el.outerHTML.substring(0, 100)}`);
            }
        });

        return issues;
    });

    expect(antiPatterns).toEqual([]);
});

ARIA and color contrast testing form the backbone of automated accessibility checks. They are fast to run, catch real issues, and require minimal setup. Every project should have these tests from day one.