Website Accessibility Audit
An accessibility audit identifies barriers that prevent users with vision impairments, hearing loss, mobility issues, or cognitive differences from using your website effectively. The standard is WCAG 2.1/2.2 Level AA, which is mandatory for government websites in many countries and is the de facto standard for commercial sites.
What's Included in an Audit
Automated Scanning — detects approximately 30–40% of violations: missing alt texts, insufficient contrast, incorrect heading structure, missing ARIA attributes.
Manual Testing — essential for interactive elements: keyboard navigation, screen reader compatibility, modal windows and form behavior.
Real AT Testing — NVDA + Firefox (Windows), VoiceOver + Safari (macOS/iOS), TalkBack (Android).
Automated Scanning
# axe-core via CLI
npm install -g @axe-core/cli
axe https://mysite.com --reporter=json > axe-report.json
# Lighthouse Accessibility
npx lighthouse https://mysite.com \
--only-categories=accessibility \
--output=html \
--output-path=./lighthouse-a11y.html
# Pa11y: batch scan of all pages
npm install -g pa11y-ci
# .pa11yci
defaults:
standard: WCAG2AA
runners:
- axe
- htmlcs
timeout: 30000
urls:
- https://mysite.com
- https://mysite.com/about
- https://mysite.com/contact
- https://mysite.com/blog
pa11y-ci --config .pa11yci --json > pa11y-report.json
# Parse results
node -e "
const r = require('./pa11y-report.json');
const issues = Object.values(r.results).flat();
const errors = issues.filter(i => i.type === 'error');
console.log('Total errors:', errors.length);
errors.slice(0,10).forEach(e => console.log(e.code, e.selector));
"
Key WCAG AA Criteria
| Criterion | Requirement | Common Error |
|---|---|---|
| 1.1.1 Alt Text | All images have alt text | Decorative images without alt="" |
| 1.3.1 Info and Relationships | Structure conveyed through semantics | <div> instead of <button>, <nav>, <main> |
| 1.4.3 Contrast | 4.5:1 for text, 3:1 for large text | Gray text on white background |
| 1.4.4 Resize Text | Up to 200% without content loss | Fixed height on blocks |
| 2.1.1 Keyboard | Everything accessible via keyboard | No focus on custom dropdowns |
| 2.4.3 Focus Order | Logical focus order | tabindex with arbitrary numbers |
| 2.4.7 Focus Visible | Visible focus indicator | outline: none without alternative |
| 3.3.1 Error Identification | Form errors described in text | Color only indicates error |
| 4.1.2 Name, Role, Value | ARIA for custom components | No role/aria-label on icon buttons |
Manual Checking: Checklist
Keyboard Navigation:
- Tab moves through all interactive elements
- Shift+Tab works in reverse direction
- Enter/Space activates buttons
- Escape closes modals and dropdowns
- Arrow keys work inside
<select>, radio groups, tablist
Focus Trapping:
// Proper focus management in modal window
const Modal = ({ isOpen, onClose, children }) => {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
// Save previously focused element
const previouslyFocused = document.activeElement as HTMLElement;
// Move focus to modal
modalRef.current?.focus();
return () => {
// Restore focus on close
previouslyFocused?.focus();
};
}
}, [isOpen]);
return (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
>
{children}
</div>
);
};
Contrast Checking:
# Use axe DevTools in browser — Elements tab
# Or programmatically:
node -e "
const { getContrastRatio } = require('polished');
const ratio = getContrastRatio('#767676', '#ffffff');
console.log('Ratio:', ratio.toFixed(2), ratio >= 4.5 ? 'PASS' : 'FAIL AA');
"
Common Findings and Fixes
Custom Components Without ARIA:
// BAD: icon button without description
<div onClick={handleDelete}>
<TrashIcon />
</div>
// GOOD:
<button
type="button"
onClick={handleDelete}
aria-label="Delete record"
>
<TrashIcon aria-hidden="true" />
</button>
Form Without Label–Input Connection:
<!-- BAD -->
<label>Email</label>
<input type="email" name="email">
<!-- GOOD -->
<label for="email">Email</label>
<input type="email" id="email" name="email">
<!-- Or via aria-labelledby -->
<span id="email-label">Email</span>
<input type="email" aria-labelledby="email-label">
Skip Navigation:
<!-- First page element — skip navigation link -->
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<nav>...</nav>
<main id="main-content">...</main>
.skip-link {
position: absolute;
left: -9999px;
}
.skip-link:focus {
left: 0;
top: 0;
z-index: 9999;
}
CI/CD Integration
# GitHub Actions
- name: Accessibility Check
run: |
npx pa11y-ci --config .pa11yci --threshold 0
# threshold 0 = no WCAG AA errors allowed
# Or axe in Playwright tests
- name: Run axe in E2E
run: npx playwright test accessibility.spec.ts
// accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('Homepage should not have accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
Report Format
After the audit, a structured report is provided:
- Critical (block keyboard or screen reader usage) — immediate fixing required
- Serious (WCAG AA violations) — fix within sprint
- Moderate (WCAG AAA recommendations, best practices) — roadmap
Audit for sites up to 20 pages takes 2–3 business days, including NVDA testing and manual checking of all interactive scenarios.







