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.