关于 a11y
用于检查和修复 Web 应用无障碍问题的技能,面向开发者。它结合 axe-core 扫描、键盘导航测试和代码检查,逐屏处理问题并在修改后复测。
主要功能
- 01桌面与移动视口 axe 扫描
- 02键盘导航与焦点可见性测试
- 03代码级结构问题检查
- 04按影响程度排列并修复问题
- 05复测并汇总未解决项
适用场景
- 01审查应用各页面的无障碍情况
- 02发现键盘操作和焦点问题
- 03检查移动端触控目标与对比度
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dawitlabs/ ui-skills --skill a11y安装后在 Claude Code 中输入/a11y确认已加载。
附带文件WCAG.md
详细介绍
/a11y — 无障碍审计总览
查找并修复整个应用中的所有无障碍违规。逐屏通过程序化方式(axe-core)和视觉方式(键盘截图)进行检查,涵盖从严重到轻微的问题。
完整的 WCAG 速查表和修复模式:WCAG.md
阶段 0 — 环境设置
0a. 检查 / 启动开发服务器
curl -s --max-time 2 -o /dev/null -w "%{http_code}" http://localhost:3000
如果不是 200,查找并启动开发命令,然后等待:
until curl -s --max-time 2 http://localhost:3000 > /dev/null 2>&1; do sleep 1; done && echo "ready"
0b. 查找源代码根目录
find . -type d -name "src" \
-not -path "*/node_modules/*" \
-not -path "*/.next/*" \
-not -path "*/dist/*" \
-not -path "*/.turbo/*" \
| head -1
如果没有 src/:find . -type d -name "app" -not -path "*/node_modules/*" -not -path "*/.next/*" | head -1
在全文中将结果用作 <SRC>。
0c. 验证 axe-core
find . -path "*/axe-core/package.json" -not -path "*/node_modules/.cache/*" 2>/dev/null | head -1
如果未找到:安装它(仅作为开发依赖):
pnpm add -D axe-core 2>/dev/null || npm install -D axe-core 2>/dev/null || yarn add -D axe-core
解析 axe 包的路径,以便稍后注入:
find . -path "*/axe-core/axe.min.js" -not -path "*/node_modules/.cache/*" 2>/dev/null | head -1
将其保存为 <AXE_PATH>。
0d. 编写扫描脚本
写入项目根目录(需要 node_modules)。在阶段 4 删除。
_a11y_scan.mjs:
import { chromium } from '@playwright/test';
import { mkdirSync, writeFileSync } from 'fs';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const get = (f) => { const i = process.argv.indexOf(f); return i !== -1 ? process.argv[i+1] : null; };
const url = get('--url');
const name = get('--name') ?? 'screen';
const width = parseInt(get('--width') ?? '1280');
const height = parseInt(get('--height') ?? '900');
const auth = get('--auth');
const screenshot = get('--screenshot') ?? `/tmp/a11y-${name}.png`;
const report = get('--report') ?? `/tmp/a11y-${name}.json`;
if (!url) { console.error('--url required'); process.exit(1); }
mkdirSync('/tmp/a11y', { recursive: true });
const axePath = (() => {
try { return require.resolve('axe-core'); }
catch { console.error('axe-core not found — run: pnpm add -D axe-core'); process.exit(1); }
})();
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width, height },
...(auth ? { storageState: auth } : {}),
});
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
} catch {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
}
// Inject axe-core from local node_modules
await page.addScriptTag({ path: axePath });
// Run axe scan
const results = await page.evaluate(async () => {
const r = await window.axe.run(document, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'] },
});
return {
violations: r.violations.map(v => ({
id: v.id,
impact: v.impact,
description: v.description,
help: v.help,
helpUrl: v.helpUrl,
nodes: v.nodes.map(n => ({ html: n.html, target: n.target, failureSummary: n.failureSummary })),
})),
incomplete: r.incomplete.length,
passes: r.passes.length,
};
});
await page.screenshot({ path: screenshot, fullPage: true });
await browser.close();
writeFileSync(report, JSON.stringify(results, null, 2));
const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 };
for (const v of results.violations) counts[v.impact] = (counts[v.impact] ?? 0) + 1;
console.log(`saved: ${screenshot}`);
console.log(`report: ${report}`);
console.log(`violations: critical=${counts.critical} serious=${counts.serious} moderate=${counts.moderate} minor=${counts.minor}`);
console.log(`passes: ${results.passes} | incomplete: ${results.incomplete}`);
用法:
node _a11y_scan.mjs --url http://localhost:3000 --name home
node _a11y_scan.mjs --url http://localhost:3000/dashboard --name dashboard --auth /tmp/uiux-auth-state.json
node _a11y_scan.mjs --url http://localhost:3000/settings --name settings --width 375 --height 812
每次扫描后:阅读 JSON 报告并查看截图。
0e. 编写键盘导航脚本
写入项目根目录。在阶段 4 删除。
_a11y_keyboard.mjs:
import { chromium } from '@playwright/test';
import { mkdirSync } from 'fs';
const get = (f) => { const i = process.argv.indexOf(f); return i !== -1 ? process.argv[i+1] : null; };
const url = get('--url');
const name = get('--name') ?? 'screen';
const width = parseInt(get('--width') ?? '1280');
const height = parseInt(get('--height') ?? '900');
const auth = get('--auth');
const maxTabs = parseInt(get('--max-tabs') ?? '30');
if (!url) { console.error('--url required'); process.exit(1); }
mkdirSync('/tmp/a11y', { recursive: true });
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width, height },
...(auth ? { storageState: auth } : {}),
});
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
} catch {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
}
const issues = [];
// Check for skip link as first tab stop
await page.keyboard.press('Tab');
const skipLink = await page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return null;
return { tag: el.tagName, text: el.textContent?.trim(), href: el.getAttribute('href') };
});
const hasSkipLink = skipLink?.text?.toLowerCase().includes('skip') && skipLink?.href?.startsWith('#');
if (!hasSkipLink) issues.push({ type: 'missing-skip-link', detail: 'First Tab stop is not a "Skip to main content" link' });
// Tab through interactive elements
const focusLog = [];
for (let i = 0; i < maxTabs; i++) {
await page.keyboard.press('Tab');
const focusInfo = await page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return null;
const style = getComputedStyle(el);
const rect = el.getBoundingClientRect();
// Capture focused styles
const focusedBorder = style.borderColor;
const focusedBackground = style.backgroundColor;
// Briefly blur to capture unfocused styles, then refocus
el.blur();
const blurStyle = getComputedStyle(el);
const blurBorder = blurStyle.borderColor;
const blurBackground = blurStyle.backgroundColor;
el.focus();
return {
tag: el.tagName,
role: el.getAttribute('role'),
ariaLabel: el.getAttribute('aria-label'),
text: el.textContent?.trim().slice(0, 60),
outlineWidth: style.outlineWidth,
outlineStyle: style.outlineStyle,
boxShadow: style.boxShadow,
hasBorderChange: focusedBorder !== blurBorder,
hasBackgroundChange:focusedBackground !== blurBackground,
width: Math.round(rect.width),
height: Math.round(rect.height),
};
});
if (!focusInfo) break;
// Detect focus visibility via outline, box-shadow, or meaningful border/background change.
// Compare against a snapshot of the element's non-focused style by briefly blurring and refocusing.
const hasVisibleFocus = (
(focusInfo.outlineStyle !== 'none' && focusInfo.outlineWidth !== '0px')
|| (focusInfo.boxShadow && focusInfo.boxShadow !== 'none')
|| focusInfo.hasBorderChange
|| focusInfo.hasBackgroundChange
);
const hasName = !!(focusInfo.ariaLabel || focusInfo.text);
const isTiny = focusInfo.width < 44 || focusInfo.height < 44;
if (!hasVisibleFocus) issues.push({ type: 'no-focus-visible', element: focusInfo.tag, text: focusInfo.text });
if (!hasName) issues.push({ type: 'missing-accessible-name', element: focusInfo.tag });
if (isTiny) issues.push({ type: 'small-tap-target', element: focusInfo.tag, text: focusInfo.text, size: `${focusInfo.width}×${focusInfo.height}` });
focusLog.push({ ...focusInfo, hasVisibleFocus, hasName });
}
// Screenshot after keyboard traversal (last focused state)
await page.screenshot({ path: `/tmp/a11y/${name}-keyboard.png`, fullPage: false });
await browser.close();
console.log(`keyboard screenshot: /tmp/a11y/${name}-keyboard.png`);
console.log(`tab stops visited: ${focusLog.length}`);
if (issues.length) {
console.log('issues found:');
issues.forEach(i => console.log(' -', JSON.stringify(i)));
} else {
console.log('no keyboard issues found');
}
用法:
node _a11y_keyboard.mjs --url http://localhost:3000 --name home
node _a11y_keyboard.mjs --url http://localhost:3000/dashboard --name dashboard --auth /tmp/uiux-auth-state.json --max-tabs 50
0f. 处理受身份验证保护的屏幕
ls /tmp/uiux-auth-state.json /tmp/animate-auth-state.json /tmp/copy-auth-state.json 2>/dev/null | head -1
如果找到,则对受保护路由的所有脚本调用传入 --auth <path>。
如果未找到且存在受保护的屏幕,则在项目根目录编写 _a11y_auth.mjs:
import { chromium } from '@playwright/test';
const { TEST_EMAIL, TEST_PASSWORD, LOGIN_URL, SUCCESS_URL } = process.env;
if (!TEST_EMAIL || !TEST_PASSWORD) { console.error('Set TEST_EMAIL and TEST_PASSWORD'); process.exit(1); }
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(LOGIN_URL ?? 'http://localhost:3000/sign-in');
await page.fill('input[type="email"]', TEST_EMAIL);
await page.fill('input[type="password"]', TEST_PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL(SUCCESS_URL ?? '**/dashboard**', { timeout: 15000 });
await page.context().storageState({ path: '/tmp/a11y-auth-state.json' });
await browser.close();
console.log('saved → /tmp/a11y-auth-state.json');
阶段 1 — 无障碍审计
1a. 查找所有屏幕
列出 App Router 中的每个 page.tsx、Pages Router 中的每个页面、模态框、抽屉、身份验证流程、设置和入门引导,并注明各自的路由。
1b. 对每个屏幕运行 axe 扫描
对每个屏幕分别在桌面和移动视口下运行。只有在移动视口下才会出现点击目标失败问题。
node _a11y_scan.mjs --url http://localhost:3000<ROUTE> --name <name>-desktop
node _a11y_scan.mjs --url http://localhost:3000<ROUTE> --name <name>-mobile --width 375 --height 812
阅读两份 JSON 报告和两张截图。按影响程度归类违规:
- 严重 —
impact: "critical"— 辅助技术无法访问内容 - 重大 —
impact: "serious"— 造成重大障碍,通常违反 WCAG AA - 中等 —
impact: "moderate"— 降低使用体验 - 轻微 —
impact: "minor"— 违反最佳实践
1c. 对每个屏幕运行键盘导航测试
对每个屏幕运行 _a11y_keyboard.mjs。阅读标准输出,查找问题。查看键盘截图。
node _a11y_keyboard.mjs --url http://localhost:3000<ROUTE> --name <name>
根据输出手动检查:
- 第一个 Tab 停靠点是否为跳过链接?
- 所有可交互元素是否都有可见的焦点环?
- 所有按钮和链接是否都有无障碍名称?
- 所有点击目标是否至少为 44×44px?
1d. 对结构性问题进行代码级扫描
SRC=<your source root>
# Images without alt
grep -rn "<img" "$SRC" --include="*.tsx" --include="*.jsx" | grep -v "alt="
# Buttons with no accessible name
grep -rn "<button" "$SRC" --include="*.tsx" | grep -v "aria-label\|aria-labelledby"
# onClick on non-interactive elements
grep -rn "onClick" "$SRC" --include="*.tsx" | grep -v "button\|a\|input\|select\|textarea\|role="
# Missing form labels
grep -rn "<input" "$SRC" --include="*.tsx" | grep -v "aria-label\|aria-labelledby\|id=" | grep -v "type=\"hidden\""
# Heading hierarchy — look for h3 without an h2 parent context
grep -rn "<h[1-6]" "$SRC" --include="*.tsx"
# lang attribute on html element
grep -rn "<html" "$SRC" --include="*.tsx" --include="*.html"
# color-only state signals (errors shown only with a red class, no icon/text)
grep -rn "text-red\|text-error\|border-red" "$SRC" --include="*.tsx" | head -20
# Dynamic content missing aria-live
# Find containers that update dynamically but have no live region announcement
grep -rn "useState\|useEffect\|isLoading\|isError\|toast\|notification\|alert" "$SRC" --include="*.tsx" -l | \
xargs grep -L "aria-live\|aria-atomic\|role=\"alert\"\|role=\"status\""
需要使用 aria-live 的动态内容:
- Toast / 通知消息
- 表单验证错误(提交后)
- 搜索结果数量(“找到 3 条结果”)
- 加载完成消息
- 实时数据更新
如果组件在不重新加载页面的情况下更新内容,却没有 aria-live,屏幕阅读器将无法获取这些变化——标记出来以便人工审核。
1e. 检查 prefers-reduced-motion
grep -rn "prefers-reduced-motion" "$SRC" --include="*.css" --include="*.tsx" --include="*.ts"
如果完全缺失:标记为严重问题——必须能够禁用所有动画。
继续之前,按屏幕分组展示完整审计结果。
阶段 2 — 优先级排序
为每个屏幕评分:
| 因素 | 分值 |
|---|---|
| 存在严重违规(内容无法访问) | +4 |
| 存在重大违规(违反 WCAG AA) | +3 |
| 入口 / 高流量页面(落地页、身份验证、仪表板) | +3 |
| 对收入至关重要(结账、定价、注册) | +3 |
| 键盘导航失效或被困住 | +3 |
| 存在中等违规 | +2 |
| 存在轻微违规 | +1 |
| 仅供管理员 / 高级用户使用 | −1 |
按分数从高到低排序。开始阶段 3 前请用户确认。
阶段 3 — 递归修复循环
按排名顺序逐个处理屏幕。每个屏幕最多进行 3 次迭代。
3a. 阅读违规报告
阅读阶段 1 中该屏幕的 JSON。按影响程度归类:严重 → 重大 → 中等 → 轻微。再次查看 axe 截图和键盘截图。
3b. 分析
对于每项违规,阅读源文件以了解上下文。参照 WCAG.md,找出正确的修复模式。分类如下:
- Critical — 屏幕阅读器无法访问内容、键盘陷阱、缺少 lang 属性
- Major — 颜色对比度不达标、缺少表单标签、图片缺少 alt、按钮没有名称、非语义化的交互元素
- Minor — ARIA 使用不当、标题层级跳跃、缺少跳过链接、点击目标过小、缺少 prefers-reduced-motion
3c. 修复——先修复结构问题,再修复视觉问题
优先级顺序:
- 语义结构(lang、标题、地标、列表)
- 交互元素(按钮角色、键盘访问、不要在 div 上使用 onClick)
- 名称和标签(aria-label、alt 文本、表单标签)
- ARIA 正确性(角色、状态、属性)
- 对比度(使用 ui-design-principles 中的 HSB 调整方法)
- 焦点可见性(添加
focus-visible:outline或focus-visible:ring) - 动效(如果缺少
prefers-reduced-motion,则添加) - 点击目标(增加内边距,而不是直接增加宽度/高度)
自动应用修复——无需许可:
Semantic HTML:
<div onClick> → <button>
<div role="button"> → <button> (unless ARIA role is truly needed)
<img> missing alt → alt="" for decorative, descriptive alt for informative
heading skip (h1→h3) → add missing h2
Labels:
<input> no label → add aria-label or associate <label htmlFor>
<button> no text → add aria-label describing the action
icon-only button → add aria-label, add <span className="sr-only">
ARIA:
aria-label on non-interactive div → move to the interactive child
role="button" on <a> → remove role, keep <a> with href
aria-hidden on focusable element → remove aria-hidden or tabIndex={-1}
Contrast:
Use HSB adjustments: darker = increase S + decrease B
Prefer token changes over component-level overrides
Focus:
Add to global CSS or Tailwind config:
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand
Never suppress with: outline: none (unless replacing with custom focus style)
Skip link:
Add as first child of <body>:
<a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-white focus:text-black">
Skip to main content
</a>
Add id="main-content" to the <main> element.
aria-live regions:
// Non-urgent announcements (search results, save confirmations)
<div aria-live="polite" aria-atomic="true" className="sr-only">{statusMessage ?? ''}</div>
// Urgent interruptions (form errors, session expiry)
<p role="alert">{errorMessage}</p>
// CRITICAL: container must be in the DOM BEFORE content changes.
// Wrong: {message && <div aria-live="polite">{message}</div>}
// Right: <div aria-live="polite" className="sr-only">{message ?? ''}</div>
prefers-reduced-motion:
In globals.css — verify this rule exists, add if missing:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
3d. 修复后重新扫描
sleep 3 # hot reload
node _a11y_scan.mjs --url http://localhost:3000<ROUTE> --name <name>-after-desktop
node _a11y_scan.mjs --url http://localhost:3000<ROUTE> --name <name>-after-mobile --width 375 --height 812
node _a11y_keyboard.mjs --url http://localhost:3000<ROUTE> --name <name>-after
阅读新的 JSON 报告。比较修复前后的违规数量。阅读截图。
验证:
- Critical 和 serious 违规是否已清除?
- 所有交互元素的键盘焦点是否可见?
- 跳过链接是否存在且可用?
- 所有按钮和输入框是否都有无障碍名称?
- 对比度是否达到 4.5:1(正文)和 3:1(大号文本、UI 组件)?
如果仍有任何 critical 或 serious 违规:返回 3c(增加迭代次数,最多 3 次)。
3e. 无障碍检查清单
进入下一个屏幕前:
- axe 报告中没有 critical 违规
- axe 报告中没有 serious 违规
-
<html>元素上有lang属性 - 每页有一个
<main>地标、一个<h1> - 标题层级连续(不会从 h1 跳到 h3)
- 跳过链接是第一个可聚焦元素,目标为
#main-content - 按 Tab 键可按逻辑顺序访问每个交互元素
- 每个获得焦点的元素都有可见焦点环(未移除浏览器默认样式)
- 没有键盘陷阱(可按 Tab 离开每个元素,包括通过 Escape 关闭模态框)
- 每个
<img>都有 alt——装饰性图片使用空值"",信息性图片使用描述性文本 - 每个
<input>都有关联的<label>或aria-label - 每个仅含图标的按钮都有
aria-label - 非交互元素(
<div>、<span>、<p>)上没有onClick - 颜色不是唯一的提示方式(错误信息同时使用图标和文本,而不只是颜色)
- 正文文本对比度 ≥4.5:1,大号文本 ≥3:1,UI 组件 ≥3:1
- 全局 CSS 中存在
prefers-reduced-motion规则 - 触控目标 ≥44×44px(已在键盘脚本输出中验证)
- 模态框/抽屉打开时会锁定焦点,关闭时焦点返回触发元素
- 动态内容(toast、错误信息、搜索结果数量、加载完成提示)使用
aria-live="polite"包裹;紧急消息使用role="alert" -
aria-live容器在内容变化前已存在于 DOM 中(不会随内容一同挂载和卸载)
任何 critical/serious 项未通过 → 返回 3c。第 3 次迭代后:标记并继续。
第 4 阶段 — 清理与报告
删除临时脚本:
rm -f _a11y_scan.mjs _a11y_keyboard.mjs _a11y_auth.mjs
每个屏幕完成后:
✓ Screen name (rank N, iteration K) — A critical + B serious + C moderate + D minor fixed
最终汇总表:
| Rank | 屏幕 | 路由 | Critical | Serious | Moderate | Minor | 迭代次数 | 状态 |
|---|---|---|---|---|---|---|---|---|
| 1 | ... | ... | 0 | 0 | ... | ... | ... | ✓ / ⚠ |
在底部列出所有已标记的违规,以及无法解决的具体问题。
规则
- Axe-core 是底线,不是上限。 它能发现约 30–40% 的实际无障碍问题。键盘测试和代码扫描能发现其余问题。
- 阅读 JSON,而不只是看数量。 修复前,先了解每项违规的含义。
- 语义化 HTML 优于 ARIA。 真正的
<button>永远优于<div role="button">。 - 移除焦点样式时,必须用其他样式替代。 除非用自定义焦点样式替代,否则
outline: none就是违规。 - 颜色绝不能是唯一的提示方式。 仅通过颜色呈现的每种状态变化,还必须使用图标、文本或图案。
- 先修复结构问题,再处理视觉问题。 优先处理地标、标题和键盘访问,最后处理对比度。
- 每个屏幕最多迭代 3 次。 标记剩余问题,然后继续处理。
- 绝不更改逻辑或功能。 只修改无障碍属性、语义元素和样式。
- 也要在 375px 下测试。 在桌面端合格的点击目标,在移动端可能不合格。
- 模态框焦点陷阱不可妥协。 打开的模态框若允许焦点移至背景,是严重故障。
请参阅 WCAG.md,了解完整的 WCAG 2.1 AA 快速参考和修复模式。