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
- 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
- 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
- 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
- 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
- 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:
<!-- 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.