accessibility-a11y
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseAccessibility (a11y)
无障碍开发(a11y)
Overview
概述
This skill covers building accessible web applications that work for everyone, including people using screen readers, keyboard-only navigation, switch devices, and other assistive technologies. It addresses WCAG 2.2 compliance at AA and AAA levels, correct ARIA usage, focus management, color contrast, reduced motion support, and automated testing integration.
Use this skill when building new UI components, reviewing existing interfaces for accessibility compliance, fixing a11y audit findings, or integrating automated accessibility testing into CI/CD pipelines.
本技能涵盖构建面向所有人的无障碍Web应用,包括使用屏幕阅读器、纯键盘导航、切换设备及其他辅助技术的用户。内容涉及WCAG 2.2的AA和AAA级合规要求、正确的ARIA使用方法、焦点管理、颜色对比度、减少动画支持以及自动化测试集成。
在构建新UI组件、审查现有界面的无障碍合规性、修复a11y审计问题或在CI/CD流水线中集成无障碍自动化测试时,可使用本技能。
Core Principles
核心原则
- Semantic HTML first - Native HTML elements (,
<button>,<nav>) provide accessibility for free. ARIA is a repair tool for when semantics are insufficient, not a replacement for proper HTML.<dialog> - Keyboard is the baseline - If it doesn't work with a keyboard alone, it doesn't work. Every interactive element must be focusable, operable, and have visible focus indicators.
- Test with real assistive technology - Automated tools catch ~30% of accessibility issues. The rest require manual testing with screen readers (VoiceOver, NVDA) and keyboard-only navigation.
- Progressive enhancement - Build the accessible version first, then layer on visual enhancements. Never hide content from assistive technology that sighted users can see.
- No information by color alone - Color can reinforce meaning but never be the sole indicator. Use icons, text labels, and patterns alongside color.
- 优先使用语义化HTML - 原生HTML元素(、
<button>、<nav>)自带无障碍支持。ARIA是语义不足时的修复工具,而非正确HTML的替代品。<dialog> - 键盘是基准 - 如果仅用键盘无法操作,那么它就是不可用的。每个交互元素必须可获取焦点、可操作,且有可见的焦点指示器。
- 使用真实辅助技术测试 - 自动化工具只能检测约30%的无障碍问题,其余问题需要通过屏幕阅读器(VoiceOver、NVDA)和纯键盘导航进行手动测试。
- 渐进式增强 - 先构建无障碍版本,再添加视觉增强效果。绝不要向辅助技术隐藏视力正常用户可见的内容。
- 不单独依赖颜色传递信息 - 颜色可强化含义,但绝不能作为唯一的指示符。需搭配图标、文本标签和图案一起使用。
Key Patterns
关键模式
Pattern 1: Accessible Modal Dialog
模式1:无障碍模态对话框
When to use: Any overlay that requires user interaction before returning to the main content.
Implementation:
tsx
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>
);
}css
/* 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;
}Why: The native element with provides focus trapping, Escape key handling, and proper semantics automatically. Custom modal implementations almost always have focus trap bugs. Using the native element gives you correct behavior for free.
<dialog>showModal()role="dialog"适用场景:任何需要用户交互后才能返回主内容的浮层。
实现代码:
tsx
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) {
// 记录打开前获取焦点的元素
previousFocusRef.current = document.activeElement as HTMLElement;
dialog.showModal();
} else {
dialog.close();
// 将焦点恢复到触发元素
previousFocusRef.current?.focus();
}
}, [isOpen]);
// 处理Esc键(原生dialog已处理,但需要清理逻辑)
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
// 处理背景点击
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>
);
}css
/* 焦点捕获由原生<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;
}设计原因:带有的原生元素自动提供焦点捕获、Esc键处理和正确的语义。自定义模态框实现几乎总会存在焦点捕获漏洞,使用原生元素可免费获得正确的行为。
showModal()<dialog>role="dialog"Pattern 2: Accessible Form with Error Handling
模式2:带错误处理的无障碍表单
When to use: Any form that collects user input and validates it.
Implementation:
tsx
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>
);
}css
/* 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;
}Why: Forms are the most common source of accessibility failures. This pattern ensures every field has a programmatic label, errors are announced via , error messages are linked to inputs via , and the error summary lets keyboard users jump directly to problematic fields.
role="alert"aria-describedby适用场景:任何收集用户输入并进行验证的表单。
实现代码:
tsx
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;
// 从可用描述中构建aria-describedby
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>
);
}
// 面向屏幕阅读器的表单级错误摘要
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>
);
}css
/* 仅屏幕阅读器可见的类 */
.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;
}
// 绝不要仅依赖颜色表示错误 - 需包含图标
.field-error::before {
content: "";
/* 通过background-image设置错误图标 */
}
input[aria-invalid="true"] {
border-color: var(--color-error);
/* 除颜色外,还可使用更粗的边框或图标 */
border-width: 2px;
}设计原因:表单是无障碍问题最常见的来源。此模式确保每个字段都有程序化标签,错误信息通过通知,错误消息通过与输入框关联,错误摘要允许键盘用户直接跳转到有问题的字段。
role="alert"aria-describedbyPattern 3: Keyboard Navigation for Custom Widgets
模式3:自定义组件的键盘导航
When to use: Building custom interactive components (tabs, menus, listboxes, comboboxes) that don't map to native HTML elements.
Implementation:
tsx
// 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>
);
}Why: Custom widgets must implement the expected keyboard interaction pattern from WAI-ARIA Authoring Practices. Tabs use Arrow keys to move between tabs (not Tab key), with on inactive tabs so only the active tab is in the tab order. This matches the mental model of screen reader users.
tabIndex={-1}适用场景:构建无法映射到原生HTML元素的自定义交互组件(标签页、菜单、列表框、组合框)。
实现代码:
tsx
// 遵循WAI-ARIA创作实践的无障碍标签页
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; // 不对其他按键阻止默认行为
}
e.preventDefault();
setActiveIndex(newIndex);
// 将焦点移动到新激活的标签页
const tabElement = document.getElementById(`tab-${tabs[newIndex].id}`);
tabElement?.focus();
};
return (
<div>
<div role="tablist" aria-label="内容区域">
{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创作实践中规定的键盘交互模式。标签页使用方向键在标签间切换(而非Tab键),非激活标签页设置,仅激活标签页在Tab顺序中。这符合屏幕阅读器用户的认知模型。
tabIndex={-1}Pattern 4: Live Regions for Dynamic Content
模式4:动态内容的实时区域
When to use: When content updates without a page reload and screen reader users need to be informed (notifications, search results, loading states).
Implementation:
tsx
// 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 */}
</>
);
}Why: Screen readers don't monitor the DOM for visual changes. Live regions explicitly tell assistive technology to announce new content. Use for non-urgent updates (search results, toasts) and for urgent messages (errors, session expiry).
aria-live="polite"role="alert"适用场景:内容无需页面刷新即可更新,且需要通知屏幕阅读器用户的场景(通知、搜索结果、加载状态)。
实现代码:
tsx
// 带实时区域的Toast通知系统
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>
);
}
// 紧急错误使用role="alert"(强制通知)
function CriticalError({ message }: { message: string }) {
return (
<div role="alert" className="critical-error">
{message}
</div>
);
}
// 搜索结果数量通知
function SearchResults({ query, count }: { query: string; count: number }) {
return (
<>
<div aria-live="polite" className="sr-only">
为“{query}”找到{count}条结果
</div>
{/* 视觉化结果列表 */}
</>
);
}设计原因:屏幕阅读器不会监控DOM的视觉变化。实时区域明确告知辅助技术要播报新内容。非紧急更新(搜索结果、Toast通知)使用,紧急消息(错误、会话过期)使用。
aria-live="polite"role="alert"Pattern 5: Automated Accessibility Testing
模式5:无障碍自动化测试
When to use: Every project, integrated into CI/CD and development workflow.
Implementation:
typescript
// 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();
});
});typescript
// 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([]);
});
});yaml
undefined适用场景:所有项目,需集成到CI/CD和开发工作流中。
实现代码:
typescript
// Jest + axe-core 组件测试
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
describe("LoginForm", () => {
it("应无无障碍违规", async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("错误状态下应无违规", async () => {
const { container } = render(<LoginForm />);
// 触发验证错误
fireEvent.click(screen.getByRole("button", { name: /submit/i }));
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});typescript
// Playwright + axe 端到端无障碍测试
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test.describe("accessibility", () => {
test("首页通过axe审计", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag22aa"])
.analyze();
expect(results.violations).toEqual([]);
});
test("模态框打开时通过axe审计", 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([]);
});
});yaml
undefinedGitHub Actions CI integration
GitHub Actions CI集成
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
**Why:** Automated testing catches structural issues (missing labels, invalid ARIA, contrast failures) consistently and early. Catching 30% of issues automatically in CI is better than catching 0% and discovering problems after launch. Combine with manual testing for full coverage.
---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%的问题,比上线后才发现问题要好得多。需结合手动测试实现全面覆盖。
---Color Contrast Quick Reference
颜色对比度速查
| Text Size | WCAG AA | WCAG AAA |
|---|---|---|
| Normal text (< 18px / 14px bold) | 4.5:1 | 7:1 |
| Large text (>= 18px / 14px bold) | 3:1 | 4.5:1 |
| UI components & graphical objects | 3:1 | 3:1 |
| Decorative / disabled elements | No requirement | No requirement |
| 文本尺寸 | WCAG AA | WCAG AAA |
|---|---|---|
| 常规文本(<18px / 14px粗体) | 4.5:1 | 7:1 |
| 大文本(≥18px / 14px粗体) | 3:1 | 4.5:1 |
| UI组件与图形对象 | 3:1 | 3:1 |
| 装饰性/禁用元素 | 无要求 | 无要求 |
ARIA Quick Reference
ARIA速查
| Need | Use This | Not This |
|---|---|---|
| Label for input | | |
| Describe input requirements | | Title attribute |
| Hide decorative content | | |
| Mark required field | | Only visual asterisk |
| Announce error | | Just changing text color |
| Expandable section | | Custom |
| 需求 | 正确用法 | 错误用法 |
|---|---|---|
| 输入框标签 | | 仅用 |
| 描述输入要求 | | Title属性 |
| 隐藏装饰性内容 | | |
| 标记必填字段 | | 仅用视觉星号 |
| 通知错误 | | 仅更改文本颜色 |
| 可展开区域 | | 自定义 |
Anti-Patterns
反模式
| Anti-Pattern | Why It's Bad | Better Approach |
|---|---|---|
| No keyboard access, no role, no focus | Use |
| Using ARIA where native HTML works | More complex, more bugs | |
| Breaks natural tab order | Use |
| Screen reader hears it twice | Use |
Hiding focus outlines ( | Keyboard users lose their place | Style |
| Disorients screen reader users | Let users navigate naturally |
| Images of text | Cannot be resized, not translatable | Use real text with CSS styling |
| 反模式 | 危害 | 优化方案 |
|---|---|---|
使用 | 无键盘访问权限、无角色、无焦点 | 可点击元素使用 |
| 原生HTML可用时仍使用ARIA | 更复杂、易出错 | 使用 |
| 破坏自然Tab顺序 | 仅使用 |
| 屏幕阅读器重复播报 | 仅对不可见标签使用 |
隐藏焦点轮廓( | 键盘用户丢失当前位置 | 样式化 |
页面加载时自动聚焦( | 使屏幕阅读器用户困惑 | 让用户自然导航 |
| 文本图片 | 无法调整大小、不可翻译 | 使用真实文本搭配CSS样式 |
Checklist
检查清单
- All interactive elements are keyboard accessible (Tab, Enter, Space, Escape, Arrow keys)
- Focus indicators are visible on all focusable elements ()
:focus-visible - All images have descriptive text (or
altfor decorative)alt="" - Form inputs have associated elements
<label> - Error messages are programmatically associated with inputs ()
aria-describedby - Color contrast meets WCAG AA (4.5:1 normal text, 3:1 large text)
- No information conveyed by color alone
- media query respected for animations
prefers-reduced-motion - Page has correct heading hierarchy (h1 > h2 > h3, no skips)
- Language is declared ()
<html lang="en"> - axe-core tests passing in CI
- Manual testing done with VoiceOver (macOS) or NVDA (Windows)
- 所有交互元素支持键盘访问(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"> - axe-core测试在CI中通过
- 使用VoiceOver(macOS)或NVDA(Windows)完成手动测试
Related Resources
相关资源
- Skills: (intersection of performance and a11y),
performance-engineering(accessible onboarding)growth-engineering - Rules: (visual change accessibility checks)
docs/reference/checklists/ui-visual-changes.md - Rules: (React component patterns)
docs/reference/stacks/react-typescript.md
- 技能:(性能与无障碍的交叉领域)、
performance-engineering(无障碍引导)growth-engineering - 规则:(视觉变更无障碍检查)
docs/reference/checklists/ui-visual-changes.md - 规则:(React组件模式)
docs/reference/stacks/react-typescript.md