/chrome-devtools-a11y-audit
When to Use
- Auditing a page for WCAG 2.1 AA compliance
- Finding missing ARIA labels, orphaned inputs, or broken heading hierarchy
- Testing keyboard navigation and focus management
- Checking color contrast and tap target sizes
Do NOT use for
- Performance issues (use [[chrome-devtools-lcp-debug]])
- Memory leaks (use [[chrome-devtools-memory-leak]])
- Visual regression testing
Key concept: accessibility tree vs DOM
DOM visibility (CSS opacity:0) ≠ screen-reader visibility.
opacity:0 → still in accessibility tree (screen readers see it)
display:none → removed from accessibility tree
aria-hidden="true" → removed from accessibility tree
take_snapshot returns the ACCESSIBILITY TREE — what assistive technologies see.
This is the most reliable source of truth for semantic structure.
Step 1: Lighthouse automated audit
// Start with Lighthouse for a comprehensive baseline score
const report = await mcp.call("lighthouse_audit", {
categories: ["accessibility"],
mode: "navigation", // refresh page + capture load issues
outputDirPath: "/tmp/lh-report",
});
// report.scores.accessibility → 0–1 scale (1.0 = no violations)
// report.audits.failed → list of failing checks with selectors
// Parse failures efficiently (don't read entire JSON):
// node -e "const r=require('/tmp/lh-report/report.json');
// Object.values(r.audits)
// .filter(a=>a.score!==null && a.score<1)
// .forEach(a=>console.log(JSON.stringify({
// id: a.id, title: a.title,
// items: a.details?.items?.slice(0,3)
// })))"
Step 2: Browser-native issue detection
// Chrome auto-checks for common a11y problems
const issues = await mcp.call("list_console_messages", {
types: ["issue"],
includePreservedMessages: true, // catch issues from page load
});
// Look for: missing labels, invalid ARIA, low-contrast text
// These are often critical failures missed by Lighthouse
Step 3: Semantic structure audit
// Capture accessibility tree
const snapshot = await mcp.call("take_snapshot", {
filePath: "/tmp/a11y-snapshot.json",
});
// Inspect heading levels programmatically
const headingAudit = await mcp.call("evaluate_script", {
script: `
const headings = [...document.querySelectorAll('h1,h2,h3,h4,h5,h6')]
.map(h => ({ level: parseInt(h.tagName[1]), text: h.textContent.trim().slice(0,60) }));
// Check for skipped levels
const issues = [];
for (let i = 1; i < headings.length; i++) {
if (headings[i].level > headings[i-1].level + 1)
issues.push({ skipped: true, from: headings[i-1], to: headings[i] });
}
return { headings, issues };
`
});
Step 4: Labels, forms, and images
// Find orphaned inputs (inputs without associated labels)
const orphanedInputs = await mcp.call("evaluate_script", {
script: `
[...document.querySelectorAll('input, textarea, select')]
.filter(el => {
const id = el.id;
const hasLabel = id && document.querySelector('label[for="' + id + '"]');
const hasAriaLabel = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby');
return !hasLabel && !hasAriaLabel;
})
.map(el => ({ tag: el.tagName, type: el.type, id: el.id, name: el.name }))
`
});
// Check images for alt text
const imagesWithoutAlt = await mcp.call("evaluate_script", {
script: `
[...document.querySelectorAll('img')]
.filter(img => !img.alt && !img.getAttribute('aria-hidden'))
.map(img => ({ src: img.src.split('/').pop(), id: img.id }))
`
});
Step 5: Focus and keyboard navigation
// Test keyboard focus trap detection
// Navigate to the page, then use press_key to tab through
await mcp.call("navigate_page", { url: "https://example.com/dialog" });
// Open modal/dialog
await mcp.call("click", { uid: "open-dialog-uid" });
// Tab through 10 elements and capture focused element each time
const focusTrail = [];
for (let i = 0; i < 10; i++) {
await mcp.call("press_key", { key: "Tab" });
const focused = await mcp.call("evaluate_script", {
script: `JSON.stringify({
tag: document.activeElement.tagName,
role: document.activeElement.getAttribute('role'),
label: document.activeElement.getAttribute('aria-label') || document.activeElement.textContent?.trim().slice(0,30),
class: document.activeElement.className.slice(0,40),
})`
});
focusTrail.push(JSON.parse(focused));
}
// Check: focus should stay within dialog (trap); pressing Esc should close + return focus
Step 6: Color contrast and tap targets
// Color contrast audit (Lighthouse covers this, but for manual check:)
const contrastIssues = await mcp.call("evaluate_script", {
script: `
// Rough check: find elements with suspiciously similar fg/bg
[...document.querySelectorAll('p, span, a, button, label')]
.filter(el => {
const style = window.getComputedStyle(el);
const fg = style.color;
const bg = style.backgroundColor;
// Flag if both are near-grey or near-white (rough heuristic — use Lighthouse for accuracy)
return fg === bg || (fg === 'rgb(255,255,255)' && bg === 'rgba(0,0,0,0)');
})
.slice(0,5)
.map(el => ({ text: el.textContent.trim().slice(0,30), tag: el.tagName }))
`
});
// Tap target size (WCAG 2.5.5 — 44×44px minimum)
const smallTapTargets = await mcp.call("evaluate_script", {
script: `
[...document.querySelectorAll('a, button, [role="button"]')]
.filter(el => {
const rect = el.getBoundingClientRect();
return rect.width < 44 || rect.height < 44;
})
.slice(0, 10)
.map(el => ({ tag: el.tagName, text: el.textContent.trim().slice(0,30),
w: Math.round(el.getBoundingClientRect().width),
h: Math.round(el.getBoundingClientRect().height) }))
`
});
Anti-Fake-Pass Checklist
❌ Using take_screenshot instead of take_snapshot for a11y audit → screenshot doesn't show ARIA roles
❌ Reading full Lighthouse JSON report → 500KB+ file; use jq/node filter for failures only
❌ opacity:0 treated as hidden from screen readers → still in accessibility tree; use aria-hidden
❌ Skipping browser-native issues check → Chrome catches critical errors Lighthouse misses
❌ Tab test without checking focus trap → focus escaping a dialog is a critical a11y failure
❌ Fixing "no alt text" on decorative images → add alt="" (empty) not alt="image" for decorative elements