关于 accessibility-audit
accessibility-audit 是一项指导应用无障碍审查的技能,面向开发团队和审查人员。它按审计阶段覆盖语义 HTML、键盘操作、屏幕阅读器、颜色对比及表单等方面,并提供验证脚本和模式参考。
主要功能
- 01按阶段组织 WCAG 2.1 AA 审计
- 02检查语义 HTML 与 ARIA 用法
- 03审查键盘导航和焦点管理
- 04检查屏幕阅读器支持与颜色对比
- 05审查表单、错误提示和媒体内容
- 06提供验证脚本与无障碍模式参考
适用场景
- 01发布前检查应用的无障碍情况
- 02UI 重构后复核交互和焦点行为
- 03排查无障碍投诉或合规问题
- 04为团队整理无障碍审查流程
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ mgd34msu/ goodvibes-plugin --skill accessibility-audit安装后在 Claude Code 中输入/accessibility-audit确认已加载。
附带文件references/accessibility-patterns.mdscripts/validate-accessibility-audit.sh
详细介绍
资源
scripts/
validate-accessibility-audit.sh
references/
accessibility-patterns.md
无障碍审计
本技能将指导你执行全面的无障碍审计,确保符合 WCAG 2.1 AA 标准。在验证应用是否采用包容性设计、准备无障碍评审或修复无障碍问题时,请使用本技能。
何时使用本技能
- 在发布前进行无障碍合规性评审
- 响应无障碍投诉或法律要求
- 定期审计现有应用的无障碍情况
- 在重大 UI 重构后验证无障碍功能
- 准备 WCAG 2.1 AA 认证
- 帮助团队熟悉无障碍最佳实践
审计方法
系统化的无障碍审计包含以下阶段:
阶段 1:初步勘查
目标: 梳理组件并识别无障碍关键区域。
使用 discover 梳理 UI 范围:
discover:
queries:
- id: interactive_components
type: grep
pattern: "(button|input|select|textarea|a href)"
glob: "**/*.{tsx,jsx}"
- id: form_components
type: grep
pattern: "<form|onSubmit|FormProvider"
glob: "**/*.{tsx,jsx}"
- id: aria_usage
type: grep
pattern: "aria-(label|labelledby|describedby|live|role)"
glob: "**/*.{tsx,jsx}"
- id: image_components
type: grep
pattern: "(<img|<Image|next/image)"
glob: "**/*.{tsx,jsx}"
verbosity: files_only
识别关键区域:
- 表单和输入字段
- 导航菜单和路由
- 模态对话框和覆盖层
- 数据表格和列表
- 媒体播放器(音频/视频)
- 自定义交互控件
阶段 2:语义化 HTML 审计
目标: 验证 HTML5 语义元素的使用是否恰当。
检查标题层级
搜索标题用法:
precision_grep:
queries:
- id: heading_elements
pattern: "<h[1-6]|heading.*level"
glob: "**/*.{tsx,jsx}"
output:
format: standard
常见问题:
- 跳过标题级别(h1 -> h3,中间没有 h2)
- 页面中有多个 h1 元素
- 用标题实现视觉样式,而非表达结构
- 复杂组件中缺少标题层级
正确的标题结构:
import { ReactNode } from 'react';
interface PageLayoutProps {
title: string;
children: ReactNode;
}
export function PageLayout({ title, children }: PageLayoutProps) {
return (
<div className="page-layout">
{/* Single h1 per page */}
<h1 className="text-3xl font-bold">{title}</h1>
<main>
{children}
</main>
</div>
);
}
interface SectionProps {
title: string;
children: ReactNode;
}
export function Section({ title, children }: SectionProps) {
return (
<section>
{/* h2 for main sections */}
<h2 className="text-2xl font-semibold">{title}</h2>
{children}
</section>
);
}
interface SubsectionProps {
title: string;
children: ReactNode;
}
export function Subsection({ title, children }: SubsectionProps) {
return (
<div>
{/* h3 for subsections */}
<h3 className="text-xl font-medium">{title}</h3>
{children}
</div>
);
}
检查地标区域
搜索地标用法:
precision_grep:
queries:
- id: landmarks
pattern: "(<header|<main|<nav|<aside|<footer|role=\"(banner|navigation|main|complementary|contentinfo)\")"
glob: "**/*.{tsx,jsx}"
output:
format: files_only
必需的地标:
- 使用
<header>或role="banner"表示网站页眉 - 使用
<nav>或role="navigation"表示导航 - 使用
<main>或role="main"表示主要内容(每页恰好一个) - 使用
<aside>或role="complementary"表示侧边栏 - 使用
<footer>或role="contentinfo"表示网站页脚
正确的地标结构:
import { ReactNode } from 'react';
interface AppLayoutProps {
navigation: ReactNode;
sidebar?: ReactNode;
children: ReactNode;
}
export function AppLayout({ navigation, sidebar, children }: AppLayoutProps) {
return (
<div className="app-layout">
<header className="site-header">
<div className="logo">MyApp</div>
{navigation}
</header>
<div className="content-container">
{sidebar && (
<aside className="sidebar" aria-label="Filters">
{sidebar}
</aside>
)}
{/* Exactly one main per page */}
<main className="main-content">
{children}
</main>
</div>
<footer className="site-footer">
<p>© 2026 MyApp. All rights reserved.</p>
</footer>
</div>
);
}
检查列表语义
搜索列表模式:
precision_grep:
queries:
- id: list_elements
pattern: "<(ul|ol|li|dl|dt|dd)"
glob: "**/*.{tsx,jsx}"
output:
format: files_only
常见问题:
- 使用
<div>表示列表,而非<ul>或<ol> - 在
<ul>或<ol>之外嵌套<li> - 用列表进行布局,而非表示语义分组
语义化列表用法:
interface User {
id: string;
name: string;
email: string;
}
interface UserListProps {
users: User[];
}
export function UserList({ users }: UserListProps) {
return (
<ul className="user-list" aria-label="Team members">
{users.map((user) => (
<li key={user.id}>
<span className="user-name">{user.name}</span>
<span className="user-email">{user.email}</span>
</li>
))}
</ul>
);
}
阶段 3:ARIA 模式审计
目标: 验证 ARIA 的使用和模式是否正确。
检查 ARIA 角色
搜索 ARIA 角色用法:
precision_grep:
queries:
- id: aria_roles
pattern: 'role="(button|link|dialog|alertdialog|menu|menuitem|tab|tabpanel|listbox|option)"'
glob: "**/*.{tsx,jsx}"
output:
format: standard
常见问题:
- 在语义元素上使用冗余角色(例如
<button role="button">) - 使用
role="button",却未提供键盘事件处理程序 - 复杂角色缺少必需的 ARIA 属性
- 使用抽象角色(例如
role="widget")
正确的 ARIA 按钮模式:
import { MouseEvent, KeyboardEvent } from 'react';
interface CustomButtonProps {
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}
export function CustomButton({ onClick, disabled, children }: CustomButtonProps) {
const handleClick = (e: MouseEvent<HTMLDivElement>) => {
if (!disabled) {
onClick();
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!disabled && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onClick();
}
};
return (
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
className="custom-button"
>
{children}
</div>
);
}
注意: 尽可能优先使用原生 <button> 元素。只有在绝对必要时,才在非按钮元素上使用 role="button"。
检查 ARIA 标签和描述
搜索标签模式:
precision_grep:
queries:
- id: aria_labels
pattern: "aria-(label|labelledby|describedby)"
glob: "**/*.{tsx,jsx}"
output:
format: context
常见问题:
- 交互元素没有可访问名称
aria-labelledby引用了不存在的 ID- 标签冗余(同时使用
aria-label和aria-labelledby) - 在非交互元素上使用
aria-label
正确的标签模式:
import { useId } from 'react';
interface SearchFormProps {
onSearch: (query: string) => void;
}
export function SearchForm({ onSearch }: SearchFormProps) {
const searchId = useId();
const hintId = useId();
return (
<form role="search" onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
onSearch(formData.get('query') as string);
}}>
<label htmlFor={searchId} className="sr-only">
Search articles
</label>
<input
id={searchId}
type="search"
name="query"
placeholder="Search..."
aria-describedby={hintId}
/>
<p id={hintId} className="text-sm text-gray-600">
Search by title, author, or keyword
</p>
<button type="submit" aria-label="Submit search">
<SearchIcon aria-hidden="true" />
</button>
</form>
);
}
检查实时区域
搜索实时区域用法:
precision_grep:
queries:
- id: live_regions
pattern: 'aria-live="(polite|assertive|off)"|role="(status|alert)"'
glob: "**/*.{tsx,jsx}"
output:
format: standard
实时区域最佳实践:
- 对非关键更新使用
aria-live="polite" - 对紧急消息使用
aria-live="assertive"或role="alert" - 对状态更新使用
role="status"(隐式设置aria-live="polite") - 确保实时区域在内容更新前已存在于 DOM 中
无障碍通知模式:
import { ReactNode, useEffect, useState } from 'react';
interface NotificationProps {
message: string;
type: 'success' | 'error' | 'info';
onDismiss: () => void;
}
export function Notification({ message, type, onDismiss }: NotificationProps) {
useEffect(() => {
const timer = setTimeout(onDismiss, 5000);
return () => clearTimeout(timer);
}, [onDismiss]);
return (
<div
role={type === 'error' ? 'alert' : 'status'}
aria-live={type === 'error' ? 'assertive' : 'polite'}
className={`notification notification-${type}`}
>
<p>{message}</p>
<button onClick={onDismiss} aria-label="Dismiss notification">
<CloseIcon aria-hidden="true" />
</button>
</div>
);
}
阶段 4:键盘导航审计
目标: 确保完整的键盘无障碍支持。
检查焦点管理
搜索焦点相关代码:
precision_grep:
queries:
- id: focus_management
pattern: "(focus\\(\\)|autoFocus|tabIndex|useRef.*focus)"
glob: "**/*.{tsx,jsx,ts}"
output:
format: standard
常见问题:
- 缺少焦点指示器(
:focus样式) - 模态框中存在焦点陷阱,却没有退出机制
- 应可通过键盘访问的交互元素设置了
tabIndex="-1" - 页面加载时不必要地自动聚焦元素
带焦点陷阱的无障碍模态框:
import { useEffect, useRef, ReactNode } from 'react';
import { createPortal } from 'react-dom';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
// Save previously focused element
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus first focusable element in modal
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements && focusableElements.length > 0) {
(focusableElements[0] as HTMLElement).focus();
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
// Trap focus within modal
if (e.key === 'Tab' && focusableElements) {
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore focus when modal closes
previousFocusRef.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-content"
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
<div className="modal-body">{children}</div>
<button onClick={onClose} className="modal-close">
Close
</button>
</div>
</div>,
document.body
);
}
检查跳过链接
搜索跳过链接的实现:
precision_grep:
queries:
- id: skip_links
pattern: "(skip.*main|skip.*content|skip.*navigation)"
glob: "**/*.{tsx,jsx}"
output:
format: files_only
跳过链接的实现:
export function SkipLink() {
return (
<a
href="#main-content"
className="skip-link"
>
Skip to main content
</a>
);
}
// CSS (in global styles)
// .skip-link {
// position: absolute;
// top: -40px;
// left: 0;
// background: #000;
// color: #fff;
// padding: 8px;
// text-decoration: none;
// z-index: 100;
// }
//
// .skip-link:focus {
// top: 0;
// }
检查键盘事件处理程序
搜索不支持键盘操作的 onClick:
precision_grep:
queries:
- id: onclick_handlers
pattern: "onClick=\\{"
glob: "**/*.{tsx,jsx}"
output:
format: locations
手动检查: 对每个非按钮、非链接元素上的 onClick 处理程序,确认:
- 元素具有
role="button"或适当的角色 - 元素具有
tabIndex={0},可通过键盘聚焦 onKeyDown处理程序会响应 Enter 和 Space 键
支持无障碍访问的点击处理程序模式:
import { MouseEvent, KeyboardEvent } from 'react';
interface ClickableCardProps {
title: string;
onClick: () => void;
}
export function ClickableCard({ title, onClick }: ClickableCardProps) {
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
};
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
className="clickable-card"
>
<h3>{title}</h3>
</div>
);
}
阶段 5:屏幕阅读器支持审查
目标: 验证与屏幕阅读器的兼容性。
检查视觉隐藏文本
搜索 sr-only 模式:
precision_grep:
queries:
- id: sr_only
pattern: "(sr-only|visually-hidden|screen-reader)"
glob: "**/*.{tsx,jsx,css,scss}"
output:
format: files_only
用于图标的视觉隐藏文本:
interface IconButtonProps {
onClick: () => void;
ariaLabel: string;
icon: React.ReactNode;
}
export function IconButton({ onClick, ariaLabel, icon }: IconButtonProps) {
return (
<button onClick={onClick} aria-label={ariaLabel}>
{icon}
<span className="sr-only">{ariaLabel}</span>
</button>
);
}
// CSS for sr-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;
// }
检查 aria-hidden 的用法
搜索 aria-hidden:
precision_grep:
queries:
- id: aria_hidden
pattern: 'aria-hidden="true"'
glob: "**/*.{tsx,jsx}"
output:
format: standard
常见问题:
- 在交互元素(按钮、链接)上使用
aria-hidden="true" - 对应可访问内容使用
aria-hidden="true" - 装饰性图标未设置
aria-hidden="true"
正确使用 aria-hidden:
interface ButtonWithIconProps {
onClick: () => void;
label: string;
}
export function ButtonWithIcon({ onClick, label }: ButtonWithIconProps) {
return (
<button onClick={onClick}>
{/* Icon is decorative, hide from screen readers */}
<CheckIcon aria-hidden="true" />
{/* Label provides accessible text */}
<span>{label}</span>
</button>
);
}
阶段 6:颜色与对比度审查
目标: 确保颜色对比度充足,且信息不只通过颜色传达。
检查对比度
使用浏览器 DevTools 或外部工具:
- Chrome DevTools:检查元素 -> Accessibility 面板 -> 对比度
- Firefox DevTools:检查器 -> Accessibility 面板
- axe DevTools 浏览器扩展
- Lighthouse 无障碍审查
WCAG 2.1 AA 要求:
- 普通文本(< 18pt):最低对比度为 4.5:1
- 大号文本(>= 18pt 或 >= 14pt 粗体):最低对比度为 3:1
- UI 组件和图形:最低对比度为 3:1
搜索颜色定义:
precision_grep:
queries:
- id: color_definitions
pattern: "(bg-|text-|color:|background:)"
glob: "**/*.{tsx,jsx,css,scss}"
output:
format: count_only
无障碍配色方案(Tailwind 示例):
// tailwind.config.ts
import type { Config } from 'tailwindcss';
const config: Config = {
theme: {
extend: {
colors: {
// Accessible color palette with documented contrast ratios
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
600: '#0284c7', // 4.54:1 on white (WCAG AA)
700: '#0369a1', // 7.09:1 on white (WCAG AAA)
900: '#0c4a6e', // 13.14:1 on white
},
// Error color with good contrast
error: {
600: '#dc2626', // 4.51:1 on white
700: '#b91c1c', // 6.70:1 on white
},
// Success color
success: {
600: '#16a34a', // 4.51:1 on white
700: '#15803d', // 6.68:1 on white
},
},
},
},
};
export default config;
检查仅通过颜色传达的信息
搜索状态指示器:
precision_grep:
queries:
- id: status_colors
pattern: "(bg-red|bg-green|bg-yellow|text-red|text-green|text-yellow)"
glob: "**/*.{tsx,jsx}"
output:
format: standard
手动检查: 确保通过多种方式传达状态(颜色 + 图标 + 文本)。
无障碍状态指示器:
type Status = 'success' | 'warning' | 'error' | 'info';
interface StatusBadgeProps {
status: Status;
message: string;
}
const statusConfig: Record<Status, { icon: React.ReactNode; className: string }> = {
success: { icon: <CheckCircleIcon />, className: 'bg-green-100 text-green-800' },
warning: { icon: <AlertTriangleIcon />, className: 'bg-yellow-100 text-yellow-800' },
error: { icon: <XCircleIcon />, className: 'bg-red-100 text-red-800' },
info: { icon: <InfoIcon />, className: 'bg-blue-100 text-blue-800' },
};
export function StatusBadge({ status, message }: StatusBadgeProps) {
const { icon, className } = statusConfig[status];
return (
<div className={`flex items-center gap-2 px-3 py-2 rounded ${className}`}>
{/* Icon provides visual indicator beyond color */}
<span aria-hidden="true">{icon}</span>
{/* Text provides clear status information */}
<span>{message}</span>
</div>
);
}
检查强制颜色模式
搜索 forced-colors 媒体查询:
precision_grep:
queries:
- id: forced_colors
pattern: "@media.*forced-colors|prefers-contrast"
glob: "**/*.{css,scss,tsx,jsx}"
output:
format: files_only
支持 Windows 高对比度模式:
/* Ensure borders are visible in forced-colors mode */
.card {
border: 1px solid #e5e7eb;
}
@media (forced-colors: active) {
.card {
border: 1px solid CanvasText;
}
}
/* Ensure custom controls are visible */
.custom-checkbox {
border: 2px solid #3b82f6;
}
@media (forced-colors: active) {
.custom-checkbox {
border: 2px solid ButtonText;
}
.custom-checkbox:checked {
background-color: Highlight;
}
}
阶段 7:表单与验证审查
目标: 确保表单可访问,且错误处理清晰明了。
检查表单标签
搜索 input 元素:
precision_grep:
queries:
- id: input_elements
pattern: "<input|<textarea|<select"
glob: "**/*.{tsx,jsx}"
output:
format: locations
手动检查: 确认每个输入框都通过以下方式关联了标签:
- 使用
htmlFor属性匹配的<label> aria-label属性aria-labelledby指向标签元素
常见问题:
- 只有占位符而没有标签的输入框
- 未设置
htmlFor属性的标签 - 多个输入框共用一个标签
无障碍表单模式:
import { useId, FormEvent } from 'react';
interface FormData {
email: string;
password: string;
}
interface LoginFormProps {
onSubmit: (data: FormData) => Promise<void>;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const emailId = useId();
const passwordId = useId();
const emailErrorId = useId();
const passwordErrorId = useId();
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await onSubmit({
email: formData.get('email') as string,
password: formData.get('password') as string,
});
};
return (
<form onSubmit={handleSubmit} noValidate>
<div className="form-field">
{/* Explicit label with htmlFor */}
<label htmlFor={emailId}>
Email address
</label>
<input
id={emailId}
type="email"
name="email"
required
aria-required="true"
aria-describedby={emailErrorId}
autoComplete="email"
/>
{/* Error message linked via aria-describedby */}
<p id={emailErrorId} className="error-message" role="alert">
{/* Error text populated on validation */}
</p>
</div>
<div className="form-field">
<label htmlFor={passwordId}>
Password
</label>
<input
id={passwordId}
type="password"
name="password"
required
aria-required="true"
aria-describedby={passwordErrorId}
autoComplete="current-password"
/>
<p id={passwordErrorId} className="error-message" role="alert">
{/* Error text populated on validation */}
</p>
</div>
<button type="submit">
Sign in
</button>
</form>
);
}
检查错误处理
搜索错误模式:
precision_grep:
queries:
- id: error_messages
pattern: "(error|invalid|required).*message"
glob: "**/*.{tsx,jsx,ts}"
output:
format: standard
无障碍错误处理:
import { useState, useId } from 'react';
interface FieldError {
field: string;
message: string;
}
interface FormWithValidationProps {
onSubmit: (data: Record<string, string>) => void;
}
export function FormWithValidation({ onSubmit }: FormWithValidationProps) {
const [errors, setErrors] = useState<FieldError[]>([]);
const nameId = useId();
const nameErrorId = useId();
const errorSummaryId = useId();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = formData.get('name') as string;
// Validation
const newErrors: FieldError[] = [];
if (!name || name.length < 2) {
newErrors.push({ field: 'name', message: 'Name must be at least 2 characters' });
}
if (newErrors.length > 0) {
setErrors(newErrors);
// Focus error summary for screen readers
document.getElementById(errorSummaryId)?.focus();
return;
}
setErrors([]);
onSubmit({ name });
};
const nameError = errors.find((e) => e.field === 'name');
return (
<form onSubmit={handleSubmit} noValidate>
{/* Error summary at top of form */}
{errors.length > 0 && (
<div
id={errorSummaryId}
role="alert"
aria-labelledby="error-summary-title"
className="error-summary"
tabIndex={-1}
>
<h2 id="error-summary-title">There are {errors.length} errors</h2>
<ul>
{errors.map((error) => (
<li key={error.field}>
<a href={`#${error.field}`}>{error.message}</a>
</li>
))}
</ul>
</div>
)}
<div className="form-field">
<label htmlFor={nameId}>Name</label>
<input
id={nameId}
name="name"
aria-required="true"
aria-invalid={nameError ? 'true' : 'false'}
aria-describedby={nameError ? nameErrorId : undefined}
/>
{nameError && (
<p id={nameErrorId} className="error-message" role="alert">
{nameError.message}
</p>
)}
</div>
<button type="submit">Submit</button>
</form>
);
}
检查必填字段
搜索必填字段指示器:
precision_grep:
queries:
- id: required_fields
pattern: "(required|aria-required)"
glob: "**/*.{tsx,jsx}"
output:
format: files_only
无障碍必填字段指示器:
interface RequiredFieldLabelProps {
htmlFor: string;
children: React.ReactNode;
}
export function RequiredFieldLabel({ htmlFor, children }: RequiredFieldLabelProps) {
return (
<label htmlFor={htmlFor}>
{children}
{/* Visual indicator */}
<span className="text-red-600" aria-hidden="true"> *</span>
{/* Screen reader text */}
<span className="sr-only"> (required)</span>
</label>
);
}
阶段 8:媒体无障碍审查
目标: 确保图像、视频和音频内容均可访问。
检查图像替代文本
搜索图像:
precision_grep:
queries:
- id: img_elements
pattern: "(<img|<Image|next/image)"
glob: "**/*.{tsx,jsx}"
output:
format: standard
手动检查: 确认每张图像都具有:
- 内容图像具有有意义的
alt文本 - 装饰性图像使用空的
alt="" - 链接图像的替代文本描述其功能
常见问题:
- 缺少
alt属性 - 通用替代文本(“图像”“照片”“图标”)
- 冗余替代文本(“……的图像”“……的照片”)
- 替代文本描述的是文件名(“IMG_1234.jpg”)
无障碍图像模式:
import Image from 'next/image';
interface ProductImageProps {
src: string;
productName: string;
}
// Content image with descriptive alt
export function ProductImage({ src, productName }: ProductImageProps) {
return (
<Image
src={src}
alt={`${productName} product photo`}
width={400}
height={400}
/>
);
}
// Decorative image with empty alt
export function DecorativePattern() {
return (
<div className="background-pattern">
<Image
src="/patterns/dots.svg"
alt=""
fill
aria-hidden="true"
/>
</div>
);
}
// Functional image (icon button)
interface DeleteButtonProps {
onDelete: () => void;
}
export function DeleteButton({ onDelete }: DeleteButtonProps) {
return (
<button onClick={onDelete} aria-label="Delete item">
<Image
src="/icons/trash.svg"
alt=""
width={20}
height={20}
aria-hidden="true"
/>
</button>
);
}
检查视频字幕
搜索视频元素:
precision_grep:
queries:
- id: video_elements
pattern: "(<video|<track)"
glob: "**/*.{tsx,jsx}"
output:
format: standard
带字幕的无障碍视频:
interface AccessibleVideoProps {
src: string;
captionsSrc: string;
title: string;
}
export function AccessibleVideo({ src, captionsSrc, title }: AccessibleVideoProps) {
return (
<video controls aria-label={title}>
<source src={src} type="video/mp4" />
{/* Captions for deaf/hard of hearing users */}
<track
kind="captions"
src={captionsSrc}
srcLang="en"
label="English captions"
default
/>
{/* Fallback text */}
<p>
Your browser does not support the video element.
<a href={src}>Download the video</a>
</p>
</video>
);
}
检查减少动态效果
搜索动画:
precision_grep:
queries:
- id: animations
pattern: "(animate|transition|prefers-reduced-motion)"
glob: "**/*.{tsx,jsx,css,scss}"
output:
format: files_only
遵循 prefers-reduced-motion 设置:
import { motion, useReducedMotion } from 'framer-motion';
interface AnimatedCardProps {
children: React.ReactNode;
}
export function AnimatedCard({ children }: AnimatedCardProps) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: shouldReduceMotion ? 0 : 0.3,
}}
>
{children}
</motion.div>
);
}
CSS 方法:
/* Default: animated */
.card {
transition: transform 0.3s ease;
}
.card:hover {
transform: scale(1.05);
}
/* Reduced motion: disable animations */
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card:hover {
transform: none;
}
}
自动化测试
Precision Tool 工作流
运行全面的无障碍检查:
discover:
queries:
# Missing alt attributes
- id: missing_alt
type: grep
pattern: "<img(?![^>]*alt=)"
glob: "**/*.{tsx,jsx}"
# onClick without keyboard handler
- id: onclick_no_keyboard
type: grep
pattern: 'onClick=\\{(?!.*onKeyDown)'
glob: "**/*.{tsx,jsx}"
# Non-button with role=button without keyboard
- id: role_button_no_keyboard
type: grep
pattern: 'role="button"(?!.*onKeyDown)'
glob: "**/*.{tsx,jsx}"
# Missing form labels
- id: unlabeled_inputs
type: grep
pattern: "<input(?![^>]*aria-label)(?![^>]*id=)"
glob: "**/*.{tsx,jsx}"
# Heading level skips
- id: heading_usage
type: grep
pattern: "<h[1-6]"
glob: "**/*.{tsx,jsx}"
verbosity: locations
基于浏览器的测试
运行以下工具,进行全面的自动化测试:
-
Lighthouse 无障碍审计
npx lighthouse https://localhost:3000 --only-categories=accessibility --view -
axe DevTools
- 安装:axe DevTools Chrome 扩展程序
- 运行:F12 -> axe DevTools -> Scan
-
Pa11y CI
npm install --save-dev pa11y-ci npx pa11y-ci --sitemap https://yoursite.com/sitemap.xml
审计报告
按 WCAG 标准组织问题
报告模板:
# Accessibility Audit Report
**Date:** 2026-02-16
**Auditor:** [Name]
**WCAG Version:** 2.1 Level AA
**Pages Audited:** 12
## Executive Summary
- **Critical Issues:** 3
- **Serious Issues:** 8
- **Moderate Issues:** 15
- **Minor Issues:** 22
## Findings by WCAG Principle
### 1. Perceivable
#### 1.1.1 Non-text Content (Level A) - FAIL
**Issue:** 12 images missing alt text
**Location:**
- `src/components/ProductCard.tsx:45`
- `src/components/Gallery.tsx:78`
**Impact:** Screen reader users cannot understand image content
**Recommendation:**
```typescript
// Before
<img src="/product.jpg" />
// After
<img src="/product.jpg" alt="Blue cotton t-shirt" />
1.4.3 对比度(最低)(AA 级)- 未通过
问题: 主按钮文本的对比度为 3.2:1(要求为 4.5:1)
位置: src/styles/globals.css:45
影响: 低视力用户可能无法阅读按钮文本
建议:
/* Before: #60a5fa on #3b82f6 = 3.2:1 */
.btn-primary {
background: #3b82f6;
color: #60a5fa;
}
/* After: white on #2563eb = 4.5:1 */
.btn-primary {
background: #2563eb;
color: #ffffff;
}
2. 可操作性
2.1.1 键盘(A 级)- 未通过
问题: 自定义下拉菜单无法通过键盘访问
位置: src/components/Dropdown.tsx
影响: 键盘用户无法操作下拉菜单
建议: 实现方向键导航,以及按 Enter/Space 键激活
3. 可理解性
3.3.2 标签或说明(A 级)- 未通过
问题: 表单输入缺少可见标签
位置: src/components/ContactForm.tsx:23-45
建议: 添加带有 htmlFor 属性的显式 <label> 元素
4. 稳健性
4.1.2 名称、角色、值(A 级)- 未通过
问题: 自定义复选框缺少 ARIA 状态
位置: src/components/CustomCheckbox.tsx
建议: 添加 aria-checked 状态和 role="checkbox"
优先处理事项
- 紧急: 为所有图片添加替代文本(影响 100% 的屏幕阅读器用户)
- 紧急: 修复下拉菜单的键盘导航(阻碍键盘用户)
- 高优先级: 修复主按钮的颜色对比度(影响约 4.5% 的用户)
- 高优先级: 添加表单标签(影响屏幕阅读器用户和有认知障碍的用户)
## Related Skills
- **code-review** - Apply accessibility checks during code review
- **component-architecture** - Design accessible component APIs
- **testing-strategy** - Include accessibility in test suites
## References
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
- [ARIA Authoring Practices Guide (APG)](https://www.w3.org/WAI/ARIA/apg/)
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- [A11y Project Checklist](https://www.a11yproject.com/checklist/)