accessibility by jezweb/claude-skills
npx skills add https://github.com/jezweb/claude-skills --skill accessibility状态 : 生产就绪 ✅ 最后更新 : 2026-01-14 依赖项 : 无(与框架无关) 标准 : WCAG 2.1 AA 级别
选择正确的元素 - 不要所有地方都用 div:
<!-- ❌ 错误 - 使用带 onClick 的 div -->
<div onclick="submit()">提交</div>
<div onclick="navigate()">下一页</div>
<!-- ✅ 正确 - 语义化元素 -->
<button type="submit">提交</button>
<a href="/next">下一页</a>
为何重要:
使交互元素可通过键盘访问:
/* ❌ 错误 - 移除焦点轮廓线 */
button:focus { outline: none; }
/* ✅ 正确 - 自定义无障碍轮廓线 */
button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
关键点:
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
:focus-visible 仅在键盘焦点时显示每个非文本元素都需要文本替代方案:
<!-- ❌ 错误 - 无替代文本 -->
<img src="logo.png">
<button><svg>...</svg></button>
<!-- ✅ 正确 - 适当的替代方案 -->
<img src="logo.png" alt="公司名称">
<button aria-label="关闭对话框"><svg>...</svg></button>
元素选择决策树:
需要可点击元素?
├─ 导航到另一个页面? → <a href="...">
├─ 提交表单? → <button type="submit">
├─ 打开对话框? → <button aria-haspopup="dialog">
└─ 其他操作? → <button type="button">
分组内容?
├─ 自包含的文章? → <article>
├─ 主题部分? → <section>
├─ 导航链接? → <nav>
└─ 补充信息? → <aside>
表单元素?
├─ 文本输入? → <input type="text">
├─ 多项选择? → <select> 或 <input type="radio">
├─ 切换开关? → <input type="checkbox"> 或 <button aria-pressed>
└─ 长文本? → <textarea>
完整指南请参阅 references/semantic-html.md。
黄金法则:仅在 HTML 无法表达模式时使用 ARIA。
<!-- ❌ 错误 - 不必要的 ARIA -->
<button role="button">点击我</button> <!-- 按钮已有角色 -->
<!-- ✅ 正确 - ARIA 填补语义空白 -->
<div role="dialog" aria-labelledby="title" aria-modal="true">
<h2 id="title">确认操作</h2>
<!-- 尚无原生 HTML 对话框,因此需要角色 -->
</div>
<!-- ✅ 更好 - 可用时使用原生 HTML -->
<dialog aria-labelledby="title">
<h2 id="title">确认操作</h2>
</dialog>
常见 ARIA 模式:
aria-label - 当可见标签不存在时aria-labelledby - 引用现有文本作为标签aria-describedby - 附加描述aria-live - 播报动态更新aria-expanded - 可折叠/展开状态完整模式请参阅 references/aria-patterns.md。
所有交互元素必须可通过键盘访问:
// Tab 键顺序管理
function Dialog({ onClose }) {
const dialogRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
useEffect(() => {
// 保存之前的焦点
previousFocus.current = document.activeElement as HTMLElement;
// 聚焦对话框中的第一个元素
const firstFocusable = dialogRef.current?.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
(firstFocusable as HTMLElement)?.focus();
// 将焦点限制在对话框内
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Tab') {
// 焦点陷阱逻辑在此
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// 关闭时恢复焦点
previousFocus.current?.focus();
};
}, [onClose]);
return <div ref={dialogRef} role="dialog">...</div>;
}
基本键盘模式:
完整模式请参阅 references/focus-management.md。
WCAG AA 要求:
普通文本(小于 18pt):对比度 4.5:1
大文本(18pt+ 或 14pt+ 粗体):对比度 3:1
UI 组件(按钮、边框):对比度 3:1
/* ❌ 错误 - 对比度不足 / :root { --background: #ffffff; --text: #999999; / 2.8:1 - 不符合 WCAG AA */ }
/* ✅ 正确 - 对比度足够 / :root { --background: #ffffff; --text: #595959; / 4.6:1 - 符合 WCAG AA */ }
测试工具:
完整指南请参阅 references/color-contrast.md。
每个表单输入都需要可见标签:
<!-- ❌ 错误 - 占位符不是标签 -->
<input type="email" placeholder="电子邮件地址">
<!-- ✅ 正确 - 正确的标签 -->
<label for="email">电子邮件地址</label>
<input type="email" id="email" name="email" required aria-required="true">
错误处理:
<label for="email">电子邮件地址</label>
<input
type="email"
id="email"
name="email"
aria-invalid="true"
aria-describedby="email-error"
>
<span id="email-error" role="alert">
请输入有效的电子邮件地址
</span>
动态错误的实时区域:
<div role="alert" aria-live="assertive" aria-atomic="true">
表单提交失败。请修正上面的错误。
</div>
完整模式请参阅 references/forms-validation.md。
✅ 首先使用语义化 HTML 元素(button、a、nav、article 等) ✅ 为所有非文本内容提供文本替代方案 ✅ 确保普通文本对比度 4.5:1,大文本/UI 对比度 3:1 ✅ 使所有功能可通过键盘访问 ✅ 仅使用键盘测试(拔掉鼠标) ✅ 使用屏幕阅读器测试(Windows 用 NVDA,Mac 用 VoiceOver) ✅ 使用正确的标题层级(h1 → h2 → h3,不跳过) ✅ 为所有表单输入添加可见标签 ✅ 提供焦点指示器(切勿只用 outline: none) ✅ 使用 aria-live 处理动态内容更新
❌ 使用带 onClick 的 div 代替 button ❌ 在没有替代方案的情况下移除焦点轮廓线 ❌ 仅用颜色传达信息 ❌ 使用占位符作为标签 ❌ 跳过标题级别(h1 → h3) ❌ 使用 tabindex > 0(破坏自然顺序) ❌ 在存在语义化 HTML 时添加 ARIA ❌ 忘记在关闭对话框后恢复焦点 ❌ 在可聚焦元素上使用 role="presentation" ❌ 创建键盘陷阱(无法退出)
此技能可预防 12 个已记录的无障碍问题:
错误:交互元素没有可见的焦点指示器 来源:WCAG 2.4.7(焦点可见) 发生原因:CSS 重置移除了默认轮廓线 预防:始终提供自定义的 focus-visible 样式
错误:文本对比度低于 4.5:1 来源:WCAG 1.4.3(对比度最小值) 发生原因:在白色背景上使用浅灰色文本 预防:使用对比度检查器测试所有文本颜色
错误:图像缺少 alt 属性 来源:WCAG 1.1.1(非文本内容) 发生原因:忘记添加或认为可选 预防:装饰性图像用 alt="",有意义图像用描述性 alt
错误:交互元素无法通过键盘访问 来源:WCAG 2.1.1(键盘) 发生原因:使用 div onClick 代替 button 预防:使用语义化交互元素(button、a)
错误:输入字段缺少关联标签 来源:WCAG 3.3.2(标签或说明) 发生原因:使用占位符作为标签 预防:始终使用带 for/id 关联的 <label> 元素
错误:标题层级从 h1 跳到 h3 来源:WCAG 1.3.1(信息和关系) 发生原因:使用标题进行视觉样式化而非语义化 预防:按顺序使用标题,用 CSS 设置样式
错误:Tab 键退出对话框到背景内容 来源:WCAG 2.4.3(焦点顺序) 发生原因:未实现焦点陷阱 预防:为模态对话框实现焦点陷阱
错误:屏幕阅读器不播报更新 来源:WCAG 4.1.3(状态消息) 发生原因:动态内容添加时未播报 预防:使用 aria-live="polite" 或 "assertive"
错误:仅使用颜色传达状态 来源:WCAG 1.4.1(颜色的使用) 发生原因:错误信息仅用红色文本,没有图标/文本 预防:添加图标 + 文本标签,而不仅仅是颜色
错误:链接使用“点击这里”或“阅读更多” 来源:WCAG 2.4.4(链接目的) 发生原因:通用链接文本没有上下文 预防:使用描述性链接文本或 aria-label
错误:视频/音频在没有用户控制的情况下自动播放 来源:WCAG 1.4.2(音频控制) 发生原因:autoplay 属性没有控件 预防:要求用户交互才能开始播放媒体
错误:自定义选择框/复选框没有键盘支持 来源:WCAG 4.1.2(名称、角色、值) 发生原因:使用 div 构建而没有 ARIA 预防:使用原生元素或实现完整的 ARIA 模式
<html lang="en">)<span lang="es">)1. 拔掉鼠标或隐藏光标
2. 在整个页面按 Tab 键
- 你能到达所有交互元素吗?
- 你能激活所有按钮/链接吗?
- 焦点顺序合理吗?
3. 使用 Enter/Space 激活
4. 使用 Escape 关闭对话框
5. 在菜单/标签页中使用方向键
NVDA(Windows - 免费):
VoiceOver(Mac - 内置):
测试内容:
axe DevTools(浏览器扩展 - 强烈推荐):
Lighthouse(Chrome 内置):
interface DialogProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
function Dialog({ isOpen, onClose, title, children }: DialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const previousFocus = document.activeElement as HTMLElement;
// 聚焦第一个可聚焦元素
const firstFocusable = dialogRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
) as HTMLElement;
firstFocusable?.focus();
// 焦点陷阱
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
if (e.key === 'Tab') {
const focusableElements = dialogRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements?.length) return;
const first = focusableElements[0] as HTMLElement;
const last = focusableElements[focusableElements.length - 1] as HTMLElement;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
previousFocus?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<>
{/* 背景遮罩 */}
<div
className="dialog-backdrop"
onClick={onClose}
aria-hidden="true"
/>
{/* 对话框 */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
className="dialog"
>
<h2 id="dialog-title">{title}</h2>
<div className="dialog-content">{children}</div>
<button onClick={onClose} aria-label="关闭对话框">×</button>
</div>
</>
);
}
使用时机:任何阻止与背景内容交互的模态对话框或覆盖层。
function Tabs({ tabs }: { tabs: Array<{ label: string; content: React.ReactNode }> }) {
const [activeIndex, setActiveIndex] = useState(0);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
if (e.key === 'ArrowLeft') {
e.preventDefault();
const newIndex = index === 0 ? tabs.length - 1 : index - 1;
setActiveIndex(newIndex);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
const newIndex = index === tabs.length - 1 ? 0 : index + 1;
setActiveIndex(newIndex);
} else if (e.key === 'Home') {
e.preventDefault();
setActiveIndex(0);
} else if (e.key === 'End') {
e.preventDefault();
setActiveIndex(tabs.length - 1);
}
};
return (
<div>
<div role="tablist" aria-label="内容标签页">
{tabs.map((tab, index) => (
<button
key={index}
role="tab"
aria-selected={activeIndex === index}
aria-controls={`panel-${index}`}
id={`tab-${index}`}
tabIndex={activeIndex === index ? 0 : -1}
onClick={() => setActiveIndex(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={index}
role="tabpanel"
id={`panel-${index}`}
aria-labelledby={`tab-${index}`}
hidden={activeIndex !== index}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}
使用时机:具有多个面板的标签页界面。
<!-- 放在 body 的最顶部 -->
<a href="#main-content" class="skip-link">
跳转到主要内容
</a>
<style>
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--primary);
color: white;
padding: 8px 16px;
z-index: 9999;
}
.skip-link:focus {
top: 0;
}
</style>
<!-- 然后在布局中 -->
<main id="main-content" tabindex="-1">
<!-- 页面内容 -->
</main>
使用时机:所有主要内容前有导航/页头的多页面网站。
function ContactForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const validateEmail = (email: string) => {
if (!email) return '电子邮件为必填项';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return '电子邮件无效';
return '';
};
const handleBlur = (field: string, value: string) => {
setTouched(prev => ({ ...prev, [field]: true }));
const error = validateEmail(value);
setErrors(prev => ({ ...prev, [field]: error }));
};
return (
<form>
<div>
<label htmlFor="email">电子邮件地址 *</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-invalid={touched.email && !!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
onBlur={(e) => handleBlur('email', e.target.value)}
/>
{touched.email && errors.email && (
<span id="email-error" role="alert" className="error">
{errors.email}
</span>
)}
</div>
<button type="submit">提交</button>
{/* 全局表单错误 */}
<div role="alert" aria-live="assertive" aria-atomic="true">
{/* 动态错误消息在此显示 */}
</div>
</form>
);
}
使用时机:所有带验证的表单。
深入研究的详细文档:
Claude 应何时加载这些:
使用时机:请求对现有页面/组件进行无障碍审计。
三种礼貌级别:
<!-- Polite:等待屏幕阅读器完成当前播报 -->
<div aria-live="polite">新消息:3</div>
<!-- Assertive:立即中断 -->
<div aria-live="assertive" role="alert">
错误:表单提交失败
</div>
<!-- Off:不播报(默认) -->
<div aria-live="off">加载中...</div>
最佳实践:
polite(通知、计数器)assertivearia-atomic="true" 在更改时阅读整个区域React Router 在导航时不重置焦点 - 你需要处理它:
function App() {
const location = useLocation();
const mainRef = useRef<HTMLElement>(null);
useEffect(() => {
// 路由变化时聚焦主要内容
mainRef.current?.focus();
// 向屏幕阅读器播报页面标题
const title = document.title;
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', 'polite');
announcement.textContent = `已导航到 ${title}`;
document.body.appendChild(announcement);
setTimeout(() => announcement.remove(), 1000);
}, [location.pathname]);
return <main ref={mainRef} tabIndex={-1} id="main-content">...</main>;
}
<table>
<caption>各地区月度销售额</caption>
<thead>
<tr>
<th scope="col">地区</th>
<th scope="col">第一季度</th>
<th scope="col">第二季度</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">北部</th>
<td>$10,000</td>
<td>$12,000</td>
</tr>
</tbody>
</table>
关键属性:
<caption> - 描述表格目的scope="col" - 标识列标题scope="row" - 标识行标题症状:可以按 Tab 键浏览页面,但看不到焦点位置 原因:CSS 移除了轮廓线或对比度不足 解决方案:
*:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
症状:动态内容变化但没有播报 原因:没有 aria-live 区域 解决方案:将动态内容包裹在 <div aria-live="polite"> 中或使用 role="alert"
症状:Tab 键导航到对话框后面的元素 原因:没有焦点陷阱 解决方案:实现焦点陷阱(见上面的模式 1)
症状:视觉错误出现但屏幕阅读器未注意到 原因:没有 aria-invalid 或 role="alert" 解决方案:使用 aria-invalid + aria-describedby 指向带 role="alert" 的错误消息
为每个页面/组件使用此清单:
<html lang="en"> 或适当的语言有问题?遇到问题?
references/wcag-checklist.md 获取完整要求/a11y-auditor 代理扫描你的页面标准 : WCAG 2.1 AA 级别 测试工具 : axe DevTools、Lighthouse、NVDA、VoiceOver 成功标准 : Lighthouse 分数 90+,0 关键违规
每周安装数
504
仓库
GitHub 星标数
643
首次出现
2026 年 1 月 20 日
安全审计
安装于
claude-code407
opencode342
gemini-cli334
cursor308
codex307
antigravity295
Status : Production Ready ✅ Last Updated : 2026-01-14 Dependencies : None (framework-agnostic) Standards : WCAG 2.1 Level AA
Choose the right element - don't use div for everything:
<!-- ❌ WRONG - divs with onClick -->
<div onclick="submit()">Submit</div>
<div onclick="navigate()">Next page</div>
<!-- ✅ CORRECT - semantic elements -->
<button type="submit">Submit</button>
<a href="/next">Next page</a>
Why this matters:
Make interactive elements keyboard-accessible:
/* ❌ WRONG - removes focus outline */
button:focus { outline: none; }
/* ✅ CORRECT - custom accessible outline */
button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
CRITICAL:
:focus-visible to show only on keyboard focusEvery non-text element needs a text alternative:
<!-- ❌ WRONG - no alt text -->
<img src="logo.png">
<button><svg>...</svg></button>
<!-- ✅ CORRECT - proper alternatives -->
<img src="logo.png" alt="Company Name">
<button aria-label="Close dialog"><svg>...</svg></button>
Decision tree for element selection:
Need clickable element?
├─ Navigates to another page? → <a href="...">
├─ Submits form? → <button type="submit">
├─ Opens dialog? → <button aria-haspopup="dialog">
└─ Other action? → <button type="button">
Grouping content?
├─ Self-contained article? → <article>
├─ Thematic section? → <section>
├─ Navigation links? → <nav>
└─ Supplementary info? → <aside>
Form element?
├─ Text input? → <input type="text">
├─ Multiple choice? → <select> or <input type="radio">
├─ Toggle? → <input type="checkbox"> or <button aria-pressed>
└─ Long text? → <textarea>
Seereferences/semantic-html.md for complete guide.
Golden rule: Use ARIA only when HTML can't express the pattern.
<!-- ❌ WRONG - unnecessary ARIA -->
<button role="button">Click me</button> <!-- Button already has role -->
<!-- ✅ CORRECT - ARIA fills semantic gap -->
<div role="dialog" aria-labelledby="title" aria-modal="true">
<h2 id="title">Confirm action</h2>
<!-- No HTML dialog yet, so role needed -->
</div>
<!-- ✅ BETTER - Use native HTML when available -->
<dialog aria-labelledby="title">
<h2 id="title">Confirm action</h2>
</dialog>
Common ARIA patterns:
aria-label - When visible label doesn't existaria-labelledby - Reference existing text as labelaria-describedby - Additional descriptionaria-live - Announce dynamic updatesaria-expanded - Collapsible/expandable stateSeereferences/aria-patterns.md for complete patterns.
All interactive elements must be keyboard-accessible:
// Tab order management
function Dialog({ onClose }) {
const dialogRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
useEffect(() => {
// Save previous focus
previousFocus.current = document.activeElement as HTMLElement;
// Focus first element in dialog
const firstFocusable = dialogRef.current?.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
(firstFocusable as HTMLElement)?.focus();
// Trap focus within dialog
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Tab') {
// Focus trap logic here
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore focus on close
previousFocus.current?.focus();
};
}, [onClose]);
return <div ref={dialogRef} role="dialog">...</div>;
}
Essential keyboard patterns:
Seereferences/focus-management.md for complete patterns.
WCAG AA requirements:
Normal text (under 18pt): 4.5:1 contrast ratio
Large text (18pt+ or 14pt+ bold): 3:1 contrast ratio
UI components (buttons, borders): 3:1 contrast ratio
/* ❌ WRONG - insufficient contrast / :root { --background: #ffffff; --text: #999999; / 2.8:1 - fails WCAG AA */ }
/* ✅ CORRECT - sufficient contrast / :root { --background: #ffffff; --text: #595959; / 4.6:1 - passes WCAG AA */ }
Testing tools:
Seereferences/color-contrast.md for complete guide.
Every form input needs a visible label:
<!-- ❌ WRONG - placeholder is not a label -->
<input type="email" placeholder="Email address">
<!-- ✅ CORRECT - proper label -->
<label for="email">Email address</label>
<input type="email" id="email" name="email" required aria-required="true">
Error handling:
<label for="email">Email address</label>
<input
type="email"
id="email"
name="email"
aria-invalid="true"
aria-describedby="email-error"
>
<span id="email-error" role="alert">
Please enter a valid email address
</span>
Live regions for dynamic errors:
<div role="alert" aria-live="assertive" aria-atomic="true">
Form submission failed. Please fix the errors above.
</div>
Seereferences/forms-validation.md for complete patterns.
✅ Use semantic HTML elements first (button, a, nav, article, etc.) ✅ Provide text alternatives for all non-text content ✅ Ensure 4.5:1 contrast for normal text, 3:1 for large text/UI ✅ Make all functionality keyboard accessible ✅ Test with keyboard only (unplug mouse) ✅ Test with screen reader (NVDA on Windows, VoiceOver on Mac) ✅ Use proper heading hierarchy (h1 → h2 → h3, no skipping) ✅ Label all form inputs with visible labels ✅ Provide focus indicators (never just outline: none) ✅ Use aria-live for dynamic content updates
❌ Use div with onClick instead of button ❌ Remove focus outlines without replacement ❌ Use color alone to convey information ❌ Use placeholders as labels ❌ Skip heading levels (h1 → h3) ❌ Use tabindex > 0 (messes with natural order) ❌ Add ARIA when semantic HTML exists ❌ Forget to restore focus after closing dialogs ❌ Use role="presentation" on focusable elements ❌ Create keyboard traps (no way to escape)
This skill prevents 12 documented accessibility issues:
Error : Interactive elements have no visible focus indicator Source : WCAG 2.4.7 (Focus Visible) Why It Happens : CSS reset removes default outline Prevention : Always provide custom focus-visible styles
Error : Text has less than 4.5:1 contrast ratio Source : WCAG 1.4.3 (Contrast Minimum) Why It Happens : Using light gray text on white background Prevention : Test all text colors with contrast checker
Error : Images missing alt attributes Source : WCAG 1.1.1 (Non-text Content) Why It Happens : Forgot to add or thought it was optional Prevention : Add alt="" for decorative, descriptive alt for meaningful images
Error : Interactive elements not reachable by keyboard Source : WCAG 2.1.1 (Keyboard) Why It Happens : Using div onClick instead of button Prevention : Use semantic interactive elements (button, a)
Error : Input fields missing associated labels Source : WCAG 3.3.2 (Labels or Instructions) Why It Happens : Using placeholder as label Prevention : Always use <label> element with for/id association
Error : Heading hierarchy jumps from h1 to h3 Source : WCAG 1.3.1 (Info and Relationships) Why It Happens : Using headings for visual styling instead of semantics Prevention : Use headings in order, style with CSS
Error : Tab key exits dialog to background content Source : WCAG 2.4.3 (Focus Order) Why It Happens : No focus trap implementation Prevention : Implement focus trap for modal dialogs
Error : Screen reader doesn't announce updates Source : WCAG 4.1.3 (Status Messages) Why It Happens : Dynamic content added without announcement Prevention : Use aria-live="polite" or "assertive"
Error : Using only color to convey status Source : WCAG 1.4.1 (Use of Color) Why It Happens : Red text for errors without icon/text Prevention : Add icon + text label, not just color
Error : Links with "click here" or "read more" Source : WCAG 2.4.4 (Link Purpose) Why It Happens : Generic link text without context Prevention : Use descriptive link text or aria-label
Error : Video/audio auto-plays without user control Source : WCAG 1.4.2 (Audio Control) Why It Happens : Autoplay attribute without controls Prevention : Require user interaction to start media
Error : Custom select/checkbox without keyboard support Source : WCAG 4.1.2 (Name, Role, Value) Why It Happens : Building from divs without ARIA Prevention : Use native elements or implement full ARIA pattern
<html lang="en">)<span lang="es">)1. Unplug mouse or hide cursor
2. Tab through entire page
- Can you reach all interactive elements?
- Can you activate all buttons/links?
- Is focus order logical?
3. Use Enter/Space to activate
4. Use Escape to close dialogs
5. Use arrow keys in menus/tabs
NVDA (Windows - Free) :
VoiceOver (Mac - Built-in) :
What to test:
axe DevTools (Browser extension - highly recommended):
Lighthouse (Built into Chrome):
interface DialogProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
function Dialog({ isOpen, onClose, title, children }: DialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const previousFocus = document.activeElement as HTMLElement;
// Focus first focusable element
const firstFocusable = dialogRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
) as HTMLElement;
firstFocusable?.focus();
// Focus trap
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
if (e.key === 'Tab') {
const focusableElements = dialogRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements?.length) return;
const first = focusableElements[0] as HTMLElement;
const last = focusableElements[focusableElements.length - 1] as HTMLElement;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
previousFocus?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="dialog-backdrop"
onClick={onClose}
aria-hidden="true"
/>
{/* Dialog */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
className="dialog"
>
<h2 id="dialog-title">{title}</h2>
<div className="dialog-content">{children}</div>
<button onClick={onClose} aria-label="Close dialog">×</button>
</div>
</>
);
}
When to use : Any modal dialog or overlay that blocks interaction with background content.
function Tabs({ tabs }: { tabs: Array<{ label: string; content: React.ReactNode }> }) {
const [activeIndex, setActiveIndex] = useState(0);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
if (e.key === 'ArrowLeft') {
e.preventDefault();
const newIndex = index === 0 ? tabs.length - 1 : index - 1;
setActiveIndex(newIndex);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
const newIndex = index === tabs.length - 1 ? 0 : index + 1;
setActiveIndex(newIndex);
} else if (e.key === 'Home') {
e.preventDefault();
setActiveIndex(0);
} else if (e.key === 'End') {
e.preventDefault();
setActiveIndex(tabs.length - 1);
}
};
return (
<div>
<div role="tablist" aria-label="Content tabs">
{tabs.map((tab, index) => (
<button
key={index}
role="tab"
aria-selected={activeIndex === index}
aria-controls={`panel-${index}`}
id={`tab-${index}`}
tabIndex={activeIndex === index ? 0 : -1}
onClick={() => setActiveIndex(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={index}
role="tabpanel"
id={`panel-${index}`}
aria-labelledby={`tab-${index}`}
hidden={activeIndex !== index}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}
When to use : Tabbed interface with multiple panels.
<!-- Place at very top of body -->
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<style>
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--primary);
color: white;
padding: 8px 16px;
z-index: 9999;
}
.skip-link:focus {
top: 0;
}
</style>
<!-- Then in your layout -->
<main id="main-content" tabindex="-1">
<!-- Page content -->
</main>
When to use : All multi-page websites with navigation/header before main content.
function ContactForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const validateEmail = (email: string) => {
if (!email) return 'Email is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Email is invalid';
return '';
};
const handleBlur = (field: string, value: string) => {
setTouched(prev => ({ ...prev, [field]: true }));
const error = validateEmail(value);
setErrors(prev => ({ ...prev, [field]: error }));
};
return (
<form>
<div>
<label htmlFor="email">Email address *</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-invalid={touched.email && !!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
onBlur={(e) => handleBlur('email', e.target.value)}
/>
{touched.email && errors.email && (
<span id="email-error" role="alert" className="error">
{errors.email}
</span>
)}
</div>
<button type="submit">Submit</button>
{/* Global form error */}
<div role="alert" aria-live="assertive" aria-atomic="true">
{/* Dynamic error message appears here */}
</div>
</form>
);
}
When to use : All forms with validation.
Detailed documentation for deep dives:
When Claude should load these :
When to use : Request accessibility audit of existing page/component.
Three politeness levels:
<!-- Polite: Wait for screen reader to finish current announcement -->
<div aria-live="polite">New messages: 3</div>
<!-- Assertive: Interrupt immediately -->
<div aria-live="assertive" role="alert">
Error: Form submission failed
</div>
<!-- Off: Don't announce (default) -->
<div aria-live="off">Loading...</div>
Best practices:
polite for non-critical updates (notifications, counters)assertive for errors and critical alertsaria-atomic="true" to read entire region on changeReact Router doesn't reset focus on navigation - you need to handle it:
function App() {
const location = useLocation();
const mainRef = useRef<HTMLElement>(null);
useEffect(() => {
// Focus main content on route change
mainRef.current?.focus();
// Announce page title to screen readers
const title = document.title;
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', 'polite');
announcement.textContent = `Navigated to ${title}`;
document.body.appendChild(announcement);
setTimeout(() => announcement.remove(), 1000);
}, [location.pathname]);
return <main ref={mainRef} tabIndex={-1} id="main-content">...</main>;
}
<table>
<caption>Monthly sales by region</caption>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">North</th>
<td>$10,000</td>
<td>$12,000</td>
</tr>
</tbody>
</table>
Key attributes:
<caption> - Describes table purposescope="col" - Identifies column headersscope="row" - Identifies row headersSymptoms : Can tab through page but don't see where focus is Cause : CSS removed outlines or insufficient contrast Solution :
*:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
Symptoms : Dynamic content changes but no announcement Cause : No aria-live region Solution : Wrap dynamic content in <div aria-live="polite"> or use role="alert"
Symptoms : Tab key navigates to elements behind dialog Cause : No focus trap Solution : Implement focus trap (see Pattern 1 above)
Symptoms : Visual errors appear but screen reader doesn't notice Cause : No aria-invalid or role="alert" Solution : Use aria-invalid + aria-describedby pointing to error message with role="alert"
Use this for every page/component:
<html lang="en"> or appropriate languageQuestions? Issues?
references/wcag-checklist.md for complete requirements/a11y-auditor agent to scan your pageStandards : WCAG 2.1 Level AA Testing Tools : axe DevTools, Lighthouse, NVDA, VoiceOver Success Criteria : 90+ Lighthouse score, 0 critical violations
Weekly Installs
504
Repository
GitHub Stars
643
First Seen
Jan 20, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
claude-code407
opencode342
gemini-cli334
cursor308
codex307
antigravity295
React 组合模式指南:Vercel 组件架构最佳实践,提升代码可维护性
106,200 周安装