Accessibility Checker Skill
Purpose
This skill provides comprehensive accessibility validation against WCAG 2.1 Level AA standards, combining automated testing with manual verification procedures.
When to Use
- •Accessibility audits for new features
- •WCAG 2.1 Level AA compliance checks
- •Pre-release accessibility validation
- •Accessibility regression testing
- •Legal compliance verification (ADA, Section 508)
WCAG 2.1 Level AA Validation Workflow
1. Automated Accessibility Scanning
Using Axe-core with Playwright:
// Install axe-core
npm install -D @axe-core/playwright
// Accessibility test
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('page should not have accessibility violations', async ({ page }) => {
await page.goto('/');
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
Scan All Pages:
# Create script to scan all pages
cat > scripts/accessibility-scan.js << 'EOF'
const { chromium } = require('playwright');
const AxeBuilder = require('@axe-core/playwright').default;
async function scanPage(url) {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(url);
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
await browser.close();
return results;
}
// Scan multiple pages
const pages = [
'http://localhost:3000/',
'http://localhost:3000/about',
'http://localhost:3000/products',
];
(async () => {
for (const url of pages) {
console.log(`Scanning ${url}`);
const results = await scanPage(url);
console.log(`Violations: ${results.violations.length}`);
}
})();
EOF
node scripts/accessibility-scan.js
Deliverable: Automated scan results with violation list
2. WCAG 2.1 Principle: Perceivable
1.1 Text Alternatives:
Check Images:
# Find images without alt text
grep -r "<img" src/ | grep -v "alt="
# Using Playwright
await page.locator('img:not([alt])').count(); // Should be 0
Checklist:
- • All images have alt attributes
- • Decorative images use alt=""
- • Complex images have detailed descriptions
- • Icons have aria-label or title
- • Image buttons have descriptive text
1.3 Adaptable:
// Test: Content order makes sense
test('content order is logical', async ({ page }) => {
await page.goto('/');
// Disable CSS to check content order
await page.addStyleTag({ content: '* { all: unset !important; }' });
const textContent = await page.textContent('body');
// Verify content reads logically
});
// Test: Responsive tables
test('tables are responsive', async ({ page }) => {
await page.goto('/data');
const tables = page.locator('table');
const count = await tables.count();
for (let i = 0; i < count; i++) {
const table = tables.nth(i);
// Check for headers
await expect(table.locator('th')).toHaveCount(greaterThan(0));
// Check for scope attributes
const headers = await table.locator('th').all();
for (const header of headers) {
const scope = await header.getAttribute('scope');
expect(['col', 'row', 'colgroup', 'rowgroup']).toContain(scope);
}
}
});
Checklist:
- • Semantic HTML elements used (header, nav, main, footer)
- • Heading hierarchy logical (h1 > h2 > h3)
- • Lists use ul/ol/dl elements
- • Tables have proper headers and scope
- • Forms have fieldset and legend where appropriate
1.4 Distinguishable:
Color Contrast:
# Manual check with browser DevTools or: # Use axe-core for automated checking # Check specific contrast ratios # Text: 4.5:1 minimum # Large text (18pt+): 3:1 minimum # UI components: 3:1 minimum
// Test: Color contrast
test('text has sufficient color contrast', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
// Test: Focus indicators
test('focus indicators are visible', async ({ page }) => {
await page.goto('/');
const links = page.locator('a, button, input');
const count = await links.count();
for (let i = 0; i < count; i++) {
await page.keyboard.press('Tab');
// Check focus is visible
const focused = await page.evaluateHandle(() => document.activeElement);
const outline = await focused.evaluate(el =>
window.getComputedStyle(el).outline
);
expect(outline).not.toBe('none');
}
});
Checklist:
- • Text contrast ≥ 4.5:1 (normal text)
- • Large text contrast ≥ 3:1 (18pt+ or 14pt+ bold)
- • UI component contrast ≥ 3:1
- • Focus indicators visible (3:1 contrast with adjacent colors)
- • Color not sole means of conveying information
- • Text resizable to 200% without loss of content
- • No horizontal scrolling at 200% zoom
- • Images of text avoided (use real text)
Deliverable: Perceivable compliance report
3. WCAG 2.1 Principle: Operable
2.1 Keyboard Accessible:
// Test: Full keyboard navigation
test('all functionality available via keyboard', async ({ page }) => {
await page.goto('/');
// Tab through all interactive elements
const interactiveElements = await page.locator(
'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
).all();
for (let i = 0; i < interactiveElements.length; i++) {
await page.keyboard.press('Tab');
const focused = await page.evaluateHandle(() => document.activeElement);
const tagName = await focused.evaluate(el => el.tagName);
// Verify element is focusable
expect(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']).toContain(tagName);
}
// Verify no keyboard trap
// Tab through all elements without getting stuck
});
// Test: Skip links
test('skip link allows bypassing navigation', async ({ page }) => {
await page.goto('/');
// Press Tab to focus skip link
await page.keyboard.press('Tab');
const skipLink = page.locator('a[href="#main-content"]');
await expect(skipLink).toBeFocused();
// Activate skip link
await page.keyboard.press('Enter');
// Verify focus moved to main content
const mainContent = page.locator('#main-content');
await expect(mainContent).toBeFocused();
});
Checklist:
- • All functionality available via keyboard
- • Keyboard shortcuts don't conflict
- • Tab order is logical
- • No keyboard traps
- • Skip links present and functional
- • Custom widgets keyboard accessible
2.4 Navigable:
// Test: Page title
test('pages have descriptive titles', async ({ page }) => {
await page.goto('/products');
const title = await page.title();
expect(title).toContain('Products');
expect(title.length).toBeGreaterThan(5);
});
// Test: Heading structure
test('heading hierarchy is logical', async ({ page }) => {
await page.goto('/');
const headings = await page.locator('h1, h2, h3, h4, h5, h6').all();
const levels = await Promise.all(
headings.map(h => h.evaluate(el => parseInt(el.tagName[1])))
);
// Check h1 exists and is unique
const h1Count = levels.filter(l => l === 1).length;
expect(h1Count).toBe(1);
// Check no skipped levels
for (let i = 1; i < levels.length; i++) {
const diff = levels[i] - levels[i-1];
expect(diff).toBeLessThanOrEqual(1);
}
});
// Test: Link purpose
test('links have descriptive text', async ({ page }) => {
await page.goto('/');
const links = await page.locator('a').all();
for (const link of links) {
const text = await link.textContent();
const ariaLabel = await link.getAttribute('aria-label');
const title = await link.getAttribute('title');
const hasText = text && text.trim().length > 0;
const hasLabel = ariaLabel && ariaLabel.length > 0;
const hasTitle = title && title.length > 0;
expect(hasText || hasLabel || hasTitle).toBe(true);
// Avoid generic text
if (text) {
expect(['click here', 'read more', 'link']).not.toContain(text.toLowerCase().trim());
}
}
});
Checklist:
- • Page titles descriptive and unique
- • Focus order follows visual order
- • Link purpose clear from text or context
- • Multiple ways to find pages (nav, search, sitemap)
- • Headings and labels describe content
- • Focus visible on all interactive elements
- • Current page indicated in navigation
2.5 Input Modalities:
// Test: Touch target size
test('touch targets are at least 44x44 pixels', async ({ page }) => {
await page.goto('/');
const targets = await page.locator('a, button, input, [role="button"]').all();
for (const target of targets) {
const box = await target.boundingBox();
if (box) {
expect(box.width).toBeGreaterThanOrEqual(44);
expect(box.height).toBeGreaterThanOrEqual(44);
}
}
});
Checklist:
- • Touch targets ≥ 44x44 CSS pixels
- • Pointer cancellation available
- • Labels match visible text
- • Motion actuation has alternatives
Deliverable: Operable compliance report
4. WCAG 2.1 Principle: Understandable
3.1 Readable:
# Check language attribute
grep -r "<html" src/ | grep -v 'lang='
# Playwright check
await expect(page.locator('html')).toHaveAttribute('lang');
Checklist:
- • Page language identified (lang attribute)
- • Language changes marked (lang on elements)
- • Unusual words explained (glossary/definition)
- • Abbreviations expanded on first use
- • Reading level appropriate or simplified version available
3.2 Predictable:
// Test: Consistent navigation
test('navigation is consistent across pages', async ({ page }) => {
const pages = ['/', '/about', '/products'];
const navStructures = [];
for (const url of pages) {
await page.goto(url);
const navItems = await page.locator('nav a').allTextContents();
navStructures.push(navItems);
}
// Verify all pages have same navigation
expect(navStructures[0]).toEqual(navStructures[1]);
expect(navStructures[0]).toEqual(navStructures[2]);
});
// Test: No unexpected context changes
test('focus does not trigger unexpected changes', async ({ page }) => {
await page.goto('/form');
const url = page.url();
// Tab through form
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
// URL should not change on focus
expect(page.url()).toBe(url);
});
Checklist:
- • Consistent navigation across site
- • Consistent identification of components
- • No automatic context changes on focus
- • No unexpected form submission
- • Changes requested by user
3.3 Input Assistance:
// Test: Form labels
test('all form inputs have labels', async ({ page }) => {
await page.goto('/form');
const inputs = await page.locator('input, select, textarea').all();
for (const input of inputs) {
const id = await input.getAttribute('id');
const ariaLabel = await input.getAttribute('aria-label');
const ariaLabelledby = await input.getAttribute('aria-labelledby');
if (id) {
const label = page.locator(`label[for="${id}"]`);
const hasLabel = await label.count() > 0;
expect(hasLabel || ariaLabel || ariaLabelledby).toBe(true);
}
}
});
// Test: Error identification
test('errors are clearly identified', async ({ page }) => {
await page.goto('/form');
// Submit empty form
await page.click('button[type="submit"]');
// Check for error messages
const errors = page.locator('[role="alert"], .error-message');
await expect(errors).toHaveCount(greaterThan(0));
// Errors should be associated with fields
const inputs = await page.locator('input[aria-invalid="true"]').all();
expect(inputs.length).toBeGreaterThan(0);
});
Checklist:
- • Labels or instructions provided for inputs
- • Error identification clear and specific
- • Error suggestions provided
- • Error prevention for legal/financial/data
- • Confirmation for submissions
Deliverable: Understandable compliance report
5. WCAG 2.1 Principle: Robust
4.1 Compatible:
# Validate HTML npx html-validate "src/**/*.html" # Check ARIA usage grep -r "aria-" src/ --include="*.html" --include="*.jsx" --include="*.tsx"
// Test: Valid ARIA
test('ARIA attributes are valid', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['cat.aria'])
.analyze();
expect(results.violations).toEqual([]);
});
// Test: Name, Role, Value
test('UI components have accessible name and role', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag412'])
.analyze();
expect(results.violations).toEqual([]);
});
Checklist:
- • Valid HTML (no parsing errors)
- • Start and end tags complete
- • Unique IDs
- • ARIA roles valid
- • ARIA attributes valid for roles
- • Name, role, value for all components
- • Status messages announced
Deliverable: Robust compliance report
Manual Testing Procedures
Screen Reader Testing
VoiceOver (macOS):
# Enable VoiceOver: Cmd+F5 # Navigate: VO+arrows # Interact: VO+Shift+Down # Stop interacting: VO+Shift+Up
NVDA (Windows - Free):
# Download: https://www.nvaccess.org/ # Navigate: Arrow keys # Read all: Insert+Down # Elements list: Insert+F7
Manual Checklist:
- • All content announced
- • Heading navigation works
- • Landmarks identified
- • Forms properly labeled
- • Images described
- • Errors announced
- • Dynamic updates announced (aria-live)
Keyboard Testing
Manual Test Script:
- •Unplug mouse
- •Tab through entire page
- •Verify all functionality accessible
- •Verify focus always visible
- •Test with screen reader
- •Test keyboard shortcuts
- •Verify no keyboard traps
Zoom and Reflow Testing
# Browser zoom to 200% # Verify: # - All content visible # - No horizontal scrolling # - Text readable # - Functionality works # - Touch targets remain usable
Accessibility Report Format
# WCAG 2.1 Level AA Accessibility Report **Date**: [YYYY-MM-DD] **Application**: [name] **Pages Tested**: [count] **Testing Method**: Automated + Manual ## Executive Summary **Overall Compliance**: [XX]% compliant - **Critical Issues**: [count] (must fix) - **Serious Issues**: [count] (should fix) - **Moderate Issues**: [count] (nice to fix) - **Minor Issues**: [count] (best practice) ## WCAG 2.1 Compliance Status | Principle | Level A | Level AA | Notes | |-----------|---------|----------|-------| | Perceivable | ✅/❌ ([X]/[Y]) | ✅/❌ ([X]/[Y]) | [summary] | | Operable | ✅/❌ ([X]/[Y]) | ✅/❌ ([X]/[Y]) | [summary] | | Understandable | ✅/❌ ([X]/[Y]) | ✅/❌ ([X]/[Y]) | [summary] | | Robust | ✅/❌ ([X]/[Y]) | ✅/❌ ([X]/[Y]) | [summary] | ## Detailed Findings ### Critical: [Issue Title] **WCAG Criterion**: [X.X.X Title] **Level**: A/AA **Impact**: [who is affected] **Pages**: [list of pages] **Issue**: [description] **User Impact**: [how it affects users] **How to Fix**: ```html <!-- Before --> <img src="logo.png"> <!-- After --> <img src="logo.png" alt="Company Logo">
WCAG Reference: [link]
Testing Summary
Automated Testing (Axe-core)
- •Pages scanned: [count]
- •Violations found: [count]
- •Rules checked: [count]
Manual Testing
- •Keyboard navigation: ✅/❌
- •Screen reader (NVDA): ✅/❌
- •Screen reader (VoiceOver): ✅/❌
- •Zoom to 200%: ✅/❌
- •Mobile accessibility: ✅/❌
Browser Testing
- •Chrome: ✅/❌
- •Firefox: ✅/❌
- •Safari: ✅/❌
- •Edge: ✅/❌
Recommendations
Immediate (Critical)
- •[Fix 1]
- •[Fix 2]
Short-term (Serious)
- •[Fix 1]
Long-term (Moderate)
- •[Fix 1]
Resources
- •WCAG 2.1: https://www.w3.org/WAI/WCAG21/quickref/
- •WebAIM: https://webaim.org/
- •A11y Project: https://www.a11yproject.com/
Certification
This application [IS / IS NOT] compliant with WCAG 2.1 Level AA.
Assessor: [name] Date: [YYYY-MM-DD] Next Review: [YYYY-MM-DD]
--- ## Best Practices **Testing Approach:** - Combine automated and manual testing - Test with actual assistive technologies - Include users with disabilities in testing - Test on multiple devices and browsers **Common Issues:** - Missing alt text on images - Insufficient color contrast - Missing form labels - Keyboard traps - Poor heading structure - Missing ARIA labels - Non-semantic HTML **Quick Wins:** - Add alt attributes to images - Increase color contrast - Add skip links - Use semantic HTML - Add form labels - Logical heading hierarchy --- ## Remember - **30% rule**: Automated tools catch ~30% of issues, manual testing needed - **Real users**: Test with people who use assistive technologies - **Progressive enhancement**: Build accessibility in, don't bolt it on - **Keyboard first**: If it works with keyboard, it works with most AT - **Semantic HTML**: Use proper elements (button, not div) - **ARIA last resort**: Use semantic HTML first, ARIA when needed - **Test early**: Accessibility issues are cheaper to fix early - **Continuous**: Accessibility is ongoing, not one-time Your goal is to ensure digital experiences are accessible to all users, regardless of ability or assistive technology used.