frontend-a11y
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseAccessibility Compliance
无障碍合规性
Master accessibility implementation to create inclusive experiences that work for everyone, including users with disabilities. For performance patterns that affect rendering and interaction, see the and skills if available.
frontend-generalfrontend-react掌握无障碍实现方法,打造面向所有人(包括残障用户)的包容性体验。若需了解影响渲染与交互的性能模式,可查看和技能(如有提供)。
frontend-generalfrontend-reactWhen to Use This Skill
何时使用此技能
- Implementing WCAG 2.2 Level AA or AAA compliance
- Building screen reader accessible interfaces
- Adding keyboard navigation to interactive components
- Implementing focus management and focus trapping
- Creating accessible forms with proper labeling
- Supporting reduced motion and high contrast preferences
- Building mobile accessibility features (iOS VoiceOver, Android TalkBack)
- Conducting accessibility audits and fixing violations
- 实现WCAG 2.2 AA级或AAA级合规
- 开发适配屏幕阅读器的界面
- 为交互组件添加键盘导航
- 实现焦点管理与焦点捕获
- 创建带有正确标签的无障碍表单
- 支持减少动画与高对比度偏好设置
- 构建移动无障碍功能(iOS VoiceOver、Android TalkBack)
- 开展无障碍审计并修复违规问题
Core Capabilities
核心能力
1. WCAG 2.2 Guidelines
1. WCAG 2.2指南
- Perceivable: Content must be presentable in different ways
- Operable: Interface must be navigable with keyboard and assistive tech
- Understandable: Content and operation must be clear
- Robust: Content must work with current and future assistive technologies
- 可感知性:内容需支持多种呈现方式
- 可操作性:界面需支持键盘及辅助技术导航
- 可理解性:内容与操作方式需清晰明确
- 健壮性:内容需兼容当前及未来的辅助技术
2. ARIA Patterns
2. ARIA模式
- Roles: Define element purpose (button, dialog, navigation)
- States: Indicate current condition (expanded, selected, disabled)
- Properties: Describe relationships and additional info (labelledby, describedby)
- Live regions: Announce dynamic content changes
- 角色(Roles):定义元素用途(button、dialog、navigation等)
- 状态(States):指示当前状态(expanded、selected、disabled等)
- 属性(Properties):描述关系及附加信息(labelledby、describedby等)
- 实时区域(Live regions):播报动态内容变更
3. Keyboard Navigation
3. 键盘导航
- Focus order and tab sequence
- Focus indicators and visible focus states
- Keyboard shortcuts and hotkeys
- Focus trapping for modals and dialogs
- 焦点顺序与Tab键序列
- 焦点指示器与可见焦点状态
- 键盘快捷键与热键
- 模态框与对话框的焦点捕获
4. Screen Reader Support
4. 屏幕阅读器支持
- Semantic HTML structure
- Alternative text for images
- Proper heading hierarchy
- Skip links and landmarks
- 语义化HTML结构
- 图片替代文本
- 合理的标题层级
- 跳转链接与地标
5. Mobile Accessibility
5. 移动无障碍
- Touch target sizing (44x44dp minimum)
- VoiceOver and TalkBack compatibility
- Gesture alternatives
- Dynamic Type support
- 触摸目标尺寸(最小44x44dp)
- VoiceOver与TalkBack兼容性
- 手势替代方案
- Dynamic Type支持
Quick Reference
快速参考
WCAG 2.2 Success Criteria Checklist
WCAG 2.2成功标准检查表
| Level | Criterion | Description |
|---|---|---|
| A | 1.1.1 | Non-text content has text alternatives |
| A | 1.3.1 | Info and relationships programmatically determinable |
| A | 2.1.1 | All functionality keyboard accessible |
| A | 2.4.1 | Skip to main content mechanism |
| AA | 1.4.3 | Contrast ratio 4.5:1 (text), 3:1 (large text) |
| AA | 1.4.11 | Non-text contrast 3:1 |
| AA | 2.4.7 | Focus visible |
| AA | 2.5.8 | Target size minimum 24x24px (NEW in 2.2) |
| AAA | 1.4.6 | Enhanced contrast 7:1 |
| AAA | 2.5.5 | Target size minimum 44x44px |
| 级别 | 标准编号 | 描述 |
|---|---|---|
| A | 1.1.1 | 非文本内容配有文本替代方案 |
| A | 1.3.1 | 信息与关系可通过程序识别 |
| A | 2.1.1 | 所有功能均可通过键盘访问 |
| A | 2.4.1 | 提供跳转到主要内容的机制 |
| AA | 1.4.3 | 对比度比值:普通文本4.5:1,大文本3:1 |
| AA | 1.4.11 | 非文本内容对比度3:1 |
| AA | 2.4.7 | 焦点可见 |
| AA | 2.5.8 | 目标尺寸最小24x24px(2.2版本新增) |
| AAA | 1.4.6 | 增强对比度7:1 |
| AAA | 2.5.5 | 目标尺寸最小44x44px |
Key Patterns
关键模式
Pattern 1: Accessible Button
Pattern 1: Accessible Button
tsx
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary';
isLoading?: boolean;
}
function AccessibleButton({
children,
variant = 'primary',
isLoading = false,
disabled,
...props
}: ButtonProps) {
return (
<button
// Disable when loading
disabled={disabled || isLoading}
// Announce loading state to screen readers
aria-busy={isLoading}
// Describe the button's current state
aria-disabled={disabled || isLoading}
className={cn(
// Visible focus ring
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
// Minimum touch target size (44x44px)
'min-h-[44px] min-w-[44px]',
variant === 'primary' && 'bg-primary text-primary-foreground',
(disabled || isLoading) && 'opacity-50 cursor-not-allowed',
)}
{...props}
>
{isLoading ? (
<>
<span className="sr-only">Loading</span>
<Spinner aria-hidden="true" />
</>
) : (
children
)}
</button>
);
}tsx
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary';
isLoading?: boolean;
}
function AccessibleButton({
children,
variant = 'primary',
isLoading = false,
disabled,
...props
}: ButtonProps) {
return (
<button
// Disable when loading
disabled={disabled || isLoading}
// Announce loading state to screen readers
aria-busy={isLoading}
// Describe the button's current state
aria-disabled={disabled || isLoading}
className={cn(
// Visible focus ring
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
// Minimum touch target size (44x44px)
'min-h-[44px] min-w-[44px]',
variant === 'primary' && 'bg-primary text-primary-foreground',
(disabled || isLoading) && 'opacity-50 cursor-not-allowed',
)}
{...props}
>
{isLoading ? (
<>
<span className="sr-only">Loading</span>
<Spinner aria-hidden="true" />
</>
) : (
children
)}
</button>
);
}Pattern 2: Accessible Modal Dialog
Pattern 2: Accessible Modal Dialog
Use the native element -- it provides built-in focus trapping, Escape key handling, and behavior without third-party dependencies.
<dialog>aria-modaltsx
interface DialogProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
function AccessibleDialog({isOpen, onClose, title, children}: DialogProps) {
const dialogRef = React.useRef<HTMLDialogElement>(null);
const titleId = React.useId();
const descriptionId = React.useId();
React.useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
dialog.showModal();
} else {
dialog.close();
}
}, [isOpen]);
return (
<dialog
ref={dialogRef}
aria-labelledby={titleId}
aria-describedby={descriptionId}
onClose={onClose}
className="backdrop:bg-black/50 bg-background rounded-lg shadow-lg max-w-md w-full p-6"
>
<h2 id={titleId} className="text-lg font-semibold">
{title}
</h2>
<div id={descriptionId}>{children}</div>
<button onClick={onClose} className="absolute top-4 right-4" aria-label="Close dialog">
<X className="h-4 w-4" />
</button>
</dialog>
);
}The native element with automatically handles focus trapping, Escape to close, scroll locking, and the backdrop -- no manual effects or third-party components needed.
<dialog>showModal()FocusTrap使用原生元素——它无需第三方依赖,即可提供内置的焦点捕获、Esc键处理及行为。
<dialog>aria-modaltsx
interface DialogProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
function AccessibleDialog({isOpen, onClose, title, children}: DialogProps) {
const dialogRef = React.useRef<HTMLDialogElement>(null);
const titleId = React.useId();
const descriptionId = React.useId();
React.useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
dialog.showModal();
} else {
dialog.close();
}
}, [isOpen]);
return (
<dialog
ref={dialogRef}
aria-labelledby={titleId}
aria-describedby={descriptionId}
onClose={onClose}
className="backdrop:bg-black/50 bg-background rounded-lg shadow-lg max-w-md w-full p-6"
>
<h2 id={titleId} className="text-lg font-semibold">
{title}
</h2>
<div id={descriptionId}>{children}</div>
<button onClick={onClose} className="absolute top-4 right-4" aria-label="Close dialog">
<X className="h-4 w-4" />
</button>
</dialog>
);
}调用的原生元素会自动处理焦点捕获、Esc键关闭、滚动锁定及背景遮罩——无需手动编写效果或引入第三方组件。
showModal()<dialog>FocusTrapPattern 3: Accessible Form
Pattern 3: Accessible Form
tsx
function AccessibleForm() {
const [errors, setErrors] = React.useState<Record<string, string>>({});
return (
<form aria-describedby="form-errors" noValidate>
{/* Error summary for screen readers */}
{Object.keys(errors).length > 0 && (
<div
id="form-errors"
role="alert"
aria-live="assertive"
className="bg-destructive/10 border border-destructive p-4 rounded-md mb-4"
>
<h2 className="font-semibold text-destructive">Please fix the following errors:</h2>
<ul className="list-disc list-inside mt-2">
{Object.entries(errors).map(([field, message]) => (
<li key={field}>
<a href={`#${field}`} className="underline">
{message}
</a>
</li>
))}
</ul>
</div>
)}
{/* Required field with error */}
<div className="space-y-2">
<label htmlFor="email" className="block font-medium">
Email address
<span aria-hidden="true" className="text-destructive ml-1">
*
</span>
<span className="sr-only">(required)</span>
</label>
<input
id="email"
name="email"
type="email"
required
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : 'email-hint'}
className={cn('w-full px-3 py-2 border rounded-md', errors.email && 'border-destructive')}
/>
{errors.email ? (
<p id="email-error" className="text-sm text-destructive" role="alert">
{errors.email}
</p>
) : (
<p id="email-hint" className="text-sm text-muted-foreground">
We'll never share your email.
</p>
)}
</div>
<button type="submit" className="mt-4">
Submit
</button>
</form>
);
}tsx
function AccessibleForm() {
const [errors, setErrors] = React.useState<Record<string, string>>({});
return (
<form aria-describedby="form-errors" noValidate>
{/* Error summary for screen readers */}
{Object.keys(errors).length > 0 && (
<div
id="form-errors"
role="alert"
aria-live="assertive"
className="bg-destructive/10 border border-destructive p-4 rounded-md mb-4"
>
<h2 className="font-semibold text-destructive">Please fix the following errors:</h2>
<ul className="list-disc list-inside mt-2">
{Object.entries(errors).map(([field, message]) => (
<li key={field}>
<a href={`#${field}`} className="underline">
{message}
</a>
</li>
))}
</ul>
</div>
)}
{/* Required field with error */}
<div className="space-y-2">
<label htmlFor="email" className="block font-medium">
Email address
<span aria-hidden="true" className="text-destructive ml-1">
*
</span>
<span className="sr-only">(required)</span>
</label>
<input
id="email"
name="email"
type="email"
required
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : 'email-hint'}
className={cn('w-full px-3 py-2 border rounded-md', errors.email && 'border-destructive')}
/>
{errors.email ? (
<p id="email-error" className="text-sm text-destructive" role="alert">
{errors.email}
</p>
) : (
<p id="email-hint" className="text-sm text-muted-foreground">
We'll never share your email.
</p>
)}
</div>
<button type="submit" className="mt-4">
Submit
</button>
</form>
);
}Pattern 4: Skip Navigation Link
Pattern 4: Skip Navigation Link
tsx
function SkipLink() {
return (
<a
href="#main-content"
className={cn(
// Hidden by default, visible on focus
'sr-only focus:not-sr-only',
'focus:absolute focus:top-4 focus:left-4 focus:z-50',
'focus:bg-background focus:px-4 focus:py-2 focus:rounded-md',
'focus:ring-2 focus:ring-primary',
)}
>
Skip to main content
</a>
);
}
// In layout
function Layout({children}) {
return (
<>
<SkipLink />
<header>...</header>
<nav aria-label="Main navigation">...</nav>
<main id="main-content" tabIndex={-1}>
{children}
</main>
<footer>...</footer>
</>
);
}tsx
function SkipLink() {
return (
<a
href="#main-content"
className={cn(
// Hidden by default, visible on focus
'sr-only focus:not-sr-only',
'focus:absolute focus:top-4 focus:left-4 focus:z-50',
'focus:bg-background focus:px-4 focus:py-2 focus:rounded-md',
'focus:ring-2 focus:ring-primary',
)}
>
Skip to main content
</a>
);
}
// In layout
function Layout({children}) {
return (
<>
<SkipLink />
<header>...</header>
<nav aria-label="Main navigation">...</nav>
<main id="main-content" tabIndex={-1}>
{children}
</main>
<footer>...</footer>
</>
);
}Pattern 5: Live Region for Announcements
Pattern 5: Live Region for Announcements
tsx
function useAnnounce() {
const [message, setMessage] = React.useState('');
const [priority, setPriority] = React.useState<'polite' | 'assertive'>('polite');
const announce = React.useCallback((text: string, level: 'polite' | 'assertive' = 'polite') => {
setMessage(''); // Clear first to ensure re-announcement
setPriority(level);
setTimeout(() => setMessage(text), 100);
}, []);
const Announcer = () => (
<div
role={priority === 'assertive' ? 'alert' : 'status'}
aria-live={priority}
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
return {announce, Announcer};
}
// Usage
function SearchResults({results, isLoading}) {
const {announce, Announcer} = useAnnounce();
React.useEffect(() => {
if (!isLoading && results) {
announce(`${results.length} results found`);
}
}, [results, isLoading, announce]);
return (
<>
<Announcer />
<ul>{/* results */}</ul>
</>
);
}tsx
function useAnnounce() {
const [message, setMessage] = React.useState('');
const [priority, setPriority] = React.useState<'polite' | 'assertive'>('polite');
const announce = React.useCallback((text: string, level: 'polite' | 'assertive' = 'polite') => {
setMessage(''); // Clear first to ensure re-announcement
setPriority(level);
setTimeout(() => setMessage(text), 100);
}, []);
const Announcer = () => (
<div
role={priority === 'assertive' ? 'alert' : 'status'}
aria-live={priority}
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
return {announce, Announcer};
}
// Usage
function SearchResults({results, isLoading}) {
const {announce, Announcer} = useAnnounce();
React.useEffect(() => {
if (!isLoading && results) {
announce(`${results.length} results found`);
}
}, [results, isLoading, announce]);
return (
<>
<Announcer />
<ul>{/* results */}</ul>
</>
);
}Color Contrast Requirements
颜色对比度要求
typescript
// Contrast ratio utilities
function getContrastRatio(foreground: string, background: string): number {
const fgLuminance = getLuminance(foreground);
const bgLuminance = getLuminance(background);
const lighter = Math.max(fgLuminance, bgLuminance);
const darker = Math.min(fgLuminance, bgLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
// WCAG requirements
const CONTRAST_REQUIREMENTS = {
// Normal text (<18pt or <14pt bold)
normalText: {
AA: 4.5,
AAA: 7,
},
// Large text (>=18pt or >=14pt bold)
largeText: {
AA: 3,
AAA: 4.5,
},
// UI components and graphics
uiComponents: {
AA: 3,
},
};typescript
// Contrast ratio utilities
function getContrastRatio(foreground: string, background: string): number {
const fgLuminance = getLuminance(foreground);
const bgLuminance = getLuminance(background);
const lighter = Math.max(fgLuminance, bgLuminance);
const darker = Math.min(fgLuminance, bgLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
// WCAG requirements
const CONTRAST_REQUIREMENTS = {
// Normal text (<18pt or <14pt bold)
normalText: {
AA: 4.5,
AAA: 7,
},
// Large text (>=18pt or >=14pt bold)
largeText: {
AA: 3,
AAA: 4.5,
},
// UI components and graphics
uiComponents: {
AA: 3,
},
};Best Practices
最佳实践
- Use Semantic HTML: Prefer native elements over ARIA when possible
- Test with Real Users: Include people with disabilities in user testing
- Keyboard First: Design interactions to work without a mouse
- Don’t Disable Focus Styles: Style them, don’t remove them
- Provide Text Alternatives: All non-text content needs descriptions
- Support Zoom: Content should work at 200% zoom
- Announce Changes: Use live regions for dynamic content
- Respect Preferences: Honor prefers-reduced-motion and prefers-contrast
- 使用语义化HTML: 优先使用原生元素而非ARIA
- 与真实用户测试: 将残障用户纳入用户测试环节
- 键盘优先: 设计无需鼠标即可操作的交互
- 不要禁用焦点样式: 可自定义样式,但不要移除
- 提供文本替代方案: 所有非文本内容需配有描述
- 支持缩放: 内容需在200%缩放比例下正常显示
- 播报内容变更: 使用实时区域处理动态内容
- 尊重用户偏好: 遵循prefers-reduced-motion和prefers-contrast设置
Common Issues
常见问题
- Missing alt text: Images without descriptions
- Poor color contrast: Text hard to read against background
- Keyboard traps: Focus stuck in component
- Missing labels: Form inputs without associated labels
- Auto-playing media: Content that plays without user initiation
- Inaccessible custom controls: Recreating native functionality poorly
- Missing skip links: No way to bypass repetitive content
- Focus order issues: Tab order doesn’t match visual order
- 缺少替代文本: 图片未添加描述
- 颜色对比度不足: 文本在背景上难以辨认
- 键盘焦点陷阱: 焦点被困在组件内无法移出
- 缺少标签: 表单输入框未关联标签
- 自动播放媒体: 未经用户触发自动播放内容
- 自定义控件无障碍性差: 原生功能复刻效果不佳
- 缺少跳转链接: 无法跳过重复内容
- 焦点顺序异常: Tab键顺序与视觉顺序不符
Testing Tools
测试工具
- Automated: axe DevTools, WAVE, Lighthouse
- Manual: VoiceOver (macOS/iOS), NVDA/JAWS (Windows), TalkBack (Android)
- Simulators: NoCoffee (vision), Silktide (various disabilities)
- 自动化工具: axe DevTools、WAVE、Lighthouse
- 手动测试: VoiceOver(macOS/iOS)、NVDA/JAWS(Windows)、TalkBack(Android)
- 模拟器: NoCoffee(视觉障碍)、Silktide(多种障碍模拟)