关于 accessibility-a11y
面向 Web 开发者的无障碍设计指南,涵盖使用屏幕阅读器、键盘和其他辅助技术的用户需求。内容包括 WCAG 2.2、ARIA、焦点管理、色彩对比及自动化测试实践。
主要功能
- 01语义化 HTML 与 ARIA 使用
- 02键盘导航和焦点管理
- 03无障碍表单与错误提示
- 04动态内容实时区域模式
- 05WCAG 色彩对比参考
- 06自动化与手动无障碍测试
适用场景
- 01构建无障碍 UI 组件
- 02检查现有界面的合规问题
- 03修复无障碍审计发现
- 04在 CI/CD 中集成自动化测试
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ travisjneuman/ .claude --skill accessibility-a11y安装后在 Claude Code 中输入/accessibility-a11y确认已加载。
详细介绍
无障碍访问(a11y)
概述
这项技能涵盖构建人人都能使用的无障碍 Web 应用,包括使用屏幕阅读器、仅通过键盘导航、开关设备及其他辅助技术的人士。内容涉及符合 WCAG 2.2 AA 和 AAA 级别要求、正确使用 ARIA、焦点管理、色彩对比度、减少动态效果支持,以及自动化测试集成。
在构建新的 UI 组件、检查现有界面的无障碍合规性、修复 a11y 审计问题,或将自动化无障碍测试集成到 CI/CD 工作流时,请使用这项技能。
核心原则
- 优先使用语义化 HTML - 原生 HTML 元素(
<button>、<nav>、<dialog>)天然具备无障碍支持。语义不足时,ARIA 是一种修复手段,而不是正确 HTML 的替代品。 - 以键盘操作为基准 - 如果无法仅通过键盘操作,就说明功能不可用。每个交互元素都必须可聚焦、可操作,并具有清晰可见的焦点指示。
- 使用真实的辅助技术进行测试 - 自动化工具只能发现约 30% 的无障碍问题。其余问题需要使用屏幕阅读器(VoiceOver、NVDA)和仅键盘导航进行手动测试。
- 渐进增强 - 先构建无障碍版本,再逐步添加视觉增强。切勿向辅助技术隐藏视力正常的用户能够看到的内容。
- 不能只靠颜色传达信息 - 颜色可以强化含义,但绝不能作为唯一指示方式。应配合使用图标、文本标签和图案。
关键模式
模式 1:无障碍模态对话框
适用场景: 任何要求用户先进行交互才能返回主要内容的覆盖层。
实现:
import { useRef, useEffect, useCallback } from "react";
interface DialogProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Dialog({ isOpen, onClose, title, children }: DialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
// Store the element that had focus before opening
previousFocusRef.current = document.activeElement as HTMLElement;
dialog.showModal();
} else {
dialog.close();
// Restore focus to the triggering element
previousFocusRef.current?.focus();
}
}, [isOpen]);
// Handle Escape key (native dialog handles this, but we need cleanup)
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
// Handle backdrop click
const handleBackdropClick = useCallback(
(e: React.MouseEvent<HTMLDialogElement>) => {
if (e.target === dialogRef.current) {
onClose();
}
},
[onClose]
);
if (!isOpen) return null;
return (
<dialog
ref={dialogRef}
onClose={handleClose}
onClick={handleBackdropClick}
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
className="dialog"
>
<div className="dialog-content" role="document">
<header className="dialog-header">
<h2 id="dialog-title">{title}</h2>
<button
onClick={onClose}
aria-label="Close dialog"
className="dialog-close"
>
<span aria-hidden="true">×</span>
</button>
</header>
<div id="dialog-description">{children}</div>
</div>
</dialog>
);
}
/* Focus trap is handled natively by <dialog> showModal() */
dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
dialog .dialog-close:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
原因: 原生 <dialog> 元素配合 showModal(),可自动提供焦点陷阱、Escape 键处理和正确的 role="dialog" 语义。自定义模态框几乎总会有焦点陷阱问题。使用原生元素即可免费获得正确的行为。
模式 2:带错误处理的无障碍表单
适用场景: 任何收集用户输入并进行验证的表单。
实现:
interface FormFieldProps {
id: string;
label: string;
type?: string;
required?: boolean;
error?: string;
description?: string;
value: string;
onChange: (value: string) => void;
}
function FormField({
id,
label,
type = "text",
required = false,
error,
description,
value,
onChange,
}: FormFieldProps) {
const descriptionId = description ? `${id}-description` : undefined;
const errorId = error ? `${id}-error` : undefined;
// Build aria-describedby from available descriptions
const describedBy = [descriptionId, errorId].filter(Boolean).join(" ") || undefined;
return (
<div className="form-field">
<label htmlFor={id}>
{label}
{required && <span aria-hidden="true"> *</span>}
{required && <span className="sr-only"> (required)</span>}
</label>
{description && (
<p id={descriptionId} className="field-description">
{description}
</p>
)}
<input
id={id}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
aria-invalid={error ? "true" : undefined}
aria-describedby={describedBy}
aria-required={required}
/>
{error && (
<p id={errorId} className="field-error" role="alert">
<span aria-hidden="true">!</span> {error}
</p>
)}
</div>
);
}
// Form-level error summary for screen readers
function ErrorSummary({ errors }: { errors: Record<string, string> }) {
const errorEntries = Object.entries(errors);
if (errorEntries.length === 0) return null;
return (
<div role="alert" aria-labelledby="error-summary-title" className="error-summary">
<h3 id="error-summary-title">
{errorEntries.length} error{errorEntries.length > 1 ? "s" : ""} found
</h3>
<ul>
{errorEntries.map(([field, message]) => (
<li key={field}>
<a href={`#${field}`}>{message}</a>
</li>
))}
</ul>
</div>
);
}
/* Screen-reader only class */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.field-error {
color: var(--color-error);
font-size: 0.875rem;
margin-top: 0.25rem;
}
/* Never rely on color alone for errors - include icon */
.field-error::before {
content: "";
/* Error icon via background-image */
}
input[aria-invalid="true"] {
border-color: var(--color-error);
/* Also use a thicker border or icon, not just color */
border-width: 2px;
}
原因: 表单是无障碍问题最常见的来源。此模式可确保每个字段都有程序化标签,通过 role="alert" 播报错误,通过 aria-describedby 将错误消息与输入项关联,并提供错误摘要,让键盘用户可以直接跳转到有问题的字段。
模式 3:自定义控件的键盘导航
适用场景: 构建无法映射到原生 HTML 元素的自定义交互组件(标签页、菜单、列表框、组合框)。
实现:
// Accessible tabs following WAI-ARIA Authoring Practices
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
function Tabs({ tabs }: { tabs: Tab[] }) {
const [activeIndex, setActiveIndex] = useState(0);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
let newIndex = index;
switch (e.key) {
case "ArrowRight":
newIndex = (index + 1) % tabs.length;
break;
case "ArrowLeft":
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case "Home":
newIndex = 0;
break;
case "End":
newIndex = tabs.length - 1;
break;
default:
return; // Don't prevent default for other keys
}
e.preventDefault();
setActiveIndex(newIndex);
// Move focus to the newly active tab
const tabElement = document.getElementById(`tab-${tabs[newIndex].id}`);
tabElement?.focus();
};
return (
<div>
<div role="tablist" aria-label="Content sections">
{tabs.map((tab, index) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
role="tab"
aria-selected={index === activeIndex}
aria-controls={`panel-${tab.id}`}
tabIndex={index === activeIndex ? 0 : -1}
onClick={() => setActiveIndex(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={tab.id}
id={`panel-${tab.id}`}
role="tabpanel"
aria-labelledby={`tab-${tab.id}`}
hidden={index !== activeIndex}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}
原因: 自定义控件必须实现 WAI-ARIA Authoring Practices 规定的预期键盘交互模式。标签页使用方向键在标签页之间移动(而不是 Tab 键),并为非活动标签页设置 tabIndex={-1},这样 Tab 顺序中就只有当前活动标签页。这符合屏幕阅读器用户的心智模型。
模式 4:动态内容的实时区域
适用场景: 内容更新但页面未重新加载,且需要告知屏幕阅读器用户的情况(通知、搜索结果、加载状态)。
实现:
// Toast notification system with live regions
function ToastContainer({ toasts }: { toasts: Toast[] }) {
return (
<div
aria-live="polite"
aria-atomic="false"
aria-relevant="additions"
className="toast-container"
>
{toasts.map((toast) => (
<div
key={toast.id}
role="status"
className={`toast toast-${toast.type}`}
>
<span className="toast-icon" aria-hidden="true">
{toast.type === "success" ? "check" : "warning"}
</span>
<span>{toast.message}</span>
<button
onClick={() => dismissToast(toast.id)}
aria-label={`Dismiss: ${toast.message}`}
>
<span aria-hidden="true">×</span>
</button>
</div>
))}
</div>
);
}
// For urgent errors, use role="alert" (assertive)
function CriticalError({ message }: { message: string }) {
return (
<div role="alert" className="critical-error">
{message}
</div>
);
}
// Search results count announcement
function SearchResults({ query, count }: { query: string; count: number }) {
return (
<>
<div aria-live="polite" className="sr-only">
{count} results found for "{query}"
</div>
{/* Visual results list */}
</>
);
}
原因: 屏幕阅读器不会监控 DOM 中的视觉变化。实时区域会明确告知辅助技术播报新内容。对非紧急更新(搜索结果、toast 通知)使用 aria-live="polite",对紧急消息(错误、会话过期)使用 role="alert"。
模式 5:自动化无障碍测试
适用场景: 所有项目,集成到 CI/CD 和开发工作流中。
实现:
// Jest + axe-core for component testing
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
describe("LoginForm", () => {
it("should have no accessibility violations", async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("should have no violations in error state", async () => {
const { container } = render(<LoginForm />);
// Trigger validation errors
fireEvent.click(screen.getByRole("button", { name: /submit/i }));
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// Playwright + axe for E2E accessibility testing
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test.describe("accessibility", () => {
test("home page passes axe audit", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag22aa"])
.analyze();
expect(results.violations).toEqual([]);
});
test("modal dialog passes axe audit when open", async ({ page }) => {
await page.goto("/dashboard");
await page.getByRole("button", { name: "Create project" }).click();
const results = await new AxeBuilder({ page })
.include(".dialog")
.analyze();
expect(results.violations).toEqual([]);
});
});
# GitHub Actions CI integration
name: Accessibility Audit
on: [pull_request]
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run build
- name: Run axe accessibility tests
run: npx playwright test --grep "accessibility"
- name: Run pa11y on built pages
run: |
npx serve -s build -l 3000 &
sleep 3
npx pa11y-ci --config .pa11yci.json
原因: 自动化测试可以持续、及早地发现结构问题(缺少标签、无效的 ARIA、对比度不达标)。在 CI 中自动发现 30% 的问题,也好过完全不自动发现问题,等到发布后才发现。结合手动测试,才能实现全面覆盖。
色彩对比度速查表
| 文本大小 | WCAG AA | WCAG AAA |
|---|---|---|
| 普通文本(< 18px / 14px 粗体) | 4.5:1 | 7:1 |
| 大号文本(>= 18px / 14px 粗体) | 3:1 | 4.5:1 |
| UI 组件和图形对象 | 3:1 | 3:1 |
| 装饰性元素 / 禁用元素 | 无要求 | 无要求 |
ARIA 速查表
| 需求 | 使用此项 | 不要使用此项 |
|---|---|---|
| 输入框的标签 | <label for="x"> 或 aria-labelledby | 仅用 placeholder 作为标签 |
| 描述输入要求 | aria-describedby 指向提示文本 | Title 属性 |
| 隐藏装饰性内容 | aria-hidden="true" | display: none(会对所有人隐藏) |
| 标记必填字段 | aria-required="true" + required | 仅用视觉上的星号 |
| 播报错误 | role="alert" 或 aria-live="assertive" | 仅更改文本颜色 |
| 可展开区域 | aria-expanded="true/false" | 自定义 data-open 属性 |
反模式
| 反模式 | 问题 | 更好的做法 |
|---|---|---|
使用 <div onclick> 代替 <button> | 无法通过键盘访问、没有角色、无法获得焦点 | 可点击元素使用 <button> |
| 原生 HTML 可实现时仍使用 ARIA | 更复杂,也更容易出错 | 使用 <nav>,而非 <div role="navigation"> |
tabindex="5"(正数值) | 会破坏自然的 Tab 顺序 | 仅使用 tabindex="0" 或 tabindex="-1" |
aria-label 重复可见文本 | 屏幕阅读器会重复播报 | 仅对不可见标签使用 aria-label |
隐藏焦点轮廓(outline: none) | 键盘用户会失去当前位置 | 设置 :focus-visible 样式,而不是移除轮廓 |
页面加载时使用 autofocus | 会使屏幕阅读器用户迷失方向 | 让用户自然地浏览页面 |
| 使用文字图片 | 无法调整大小,也无法翻译 | 使用带 CSS 样式的真实文本 |
检查清单
- 所有交互元素都可通过键盘访问(Tab、Enter、Space、Escape、方向键)
- 所有可聚焦元素都有可见的焦点指示器(
:focus-visible) - 所有图片都有描述性
alt文本(装饰性图片使用alt="") - 表单输入框都有对应的
<label>元素 - 错误消息通过程序与输入框关联(
aria-describedby) - 色彩对比度符合 WCAG AA(普通文本为 4.5:1,大号文本为 3:1)
- 不单独依靠颜色传达信息
- 动画遵循
prefers-reduced-motion媒体查询 - 页面标题层级正确(h1 > h2 > h3,不跳级)
- 已声明语言(
<html lang="en">) - 在 CI 中检查 axe-core(仅在用户要求自动化无障碍测试时)
- 已使用 VoiceOver(macOS)或 NVDA(Windows)进行手动测试
相关资源
- 技能:
performance-engineering(性能与无障碍的交集)、growth-engineering(无障碍引导流程) - 规则:
docs/reference/checklists/ui-visual-changes.md(视觉变更的无障碍检查) - 规则:
docs/reference/stacks/react-typescript.md(React 组件模式)