属于我们的Compliance & Regulation系列
阅读完整指南网页辅助功能:WCAG 2.1 AA 合规指南
可访问性不是您在发布后添加的功能 - 它是一个基本的质量属性,与性能或安全性相同。欧盟(《欧洲无障碍法案》,2025 年 6 月实施)、美国(ADA 第三章判例法)和许多其他司法管辖区现已法律要求遵守 WCAG 2.1 AA。除了合规性之外,无障碍界面还可以实现更好的转化、在搜索中排名更高,并为全球约 13 亿残疾人提供服务。
本指南是实用的实施手册,而不是清单。您将学习 WCAG 的四个原则、最有影响力的技术、如何系统测试以及如何将可访问性集成到 React/Next.js 开发工作流程中以使其保持固定。
要点
- WCAG 2.1 AA 要求全部四个 POUR 原则:可感知、可操作、可理解、稳健
- 从语义 HTML 开始 — 在添加任何 ARIA 之前,它免费提供 70% 的可访问性
- 最小颜色对比度:普通文本为 4.5:1,大文本为 3:1(18pt/14pt 粗体)
- 每个交互元素都必须可通过键盘聚焦并带有可见的焦点指示器
- 屏幕阅读器基于辅助功能树进行宣布 - 使用 NVDA (Windows) 和 VoiceOver (Mac) 进行测试
- ARIA 是最后的手段 - 它只会改变辅助技术解释 DOM 的方式,而不改变行为
- 在 CI 管道中使用 axe-core 实现自动化;手动测试可以发现自动化所遗漏的内容
- 记录您的无障碍声明并为用户报告问题提供反馈机制
四项 POUR 原则
WCAG 2.1 围绕四个原则组织。每一个成功标准都属于其中一个。
可感知:信息必须以用户可以感知的方式呈现。这包括图像的文本替代、视频的字幕、足够的颜色对比度以及不单独依赖颜色来传达含义的内容。
可操作:所有功能都必须可通过键盘操作,有足够的时间进行交互,没有触发癫痫的内容,并且具有可导航的结构(跳过链接、页面标题、焦点顺序)。
可理解:内容必须可读且可预测。语言必须是可识别的,错误消息必须是描述性的,表单必须有清晰的标签和验证反馈。
稳健:内容必须可由当前和未来的辅助技术解释。这意味着有效的 HTML、正确的 ARIA 使用以及无需焦点即可宣布的状态消息。
语义 HTML 优先
语义 HTML 是一项杠杆率最高的可访问性投资。本机 HTML 元素具有内置的辅助功能角色、状态和键盘行为 - 无需 ARIA。
// BAD: Generic divs with no semantics
<div class="button" onclick="submit()">Submit</div>
<div class="nav">
<div class="link" onclick="navigate('/home')">Home</div>
</div>
// GOOD: Native semantics, free keyboard and screen reader support
<button type="submit" onClick={submit}>Submit</button>
<nav aria-label="Main navigation">
<a href="/home">Home</a>
</nav>
地标区域可帮助屏幕阅读器用户通过在各部分之间跳转来快速导航:
// Every page should have these landmarks
<header> {/* banner landmark */}
<nav aria-label="Main">...</nav>
</header>
<main> {/* main landmark */}
<h1>Page Title</h1>
<article>...</article>
<aside aria-label="Related content">...</aside>
</main>
<footer> {/* contentinfo landmark */}
<nav aria-label="Footer">...</nav>
</footer>
标题层次结构必须符合逻辑且完整:
// BAD: Skipped heading levels
<h1>Page Title</h1>
<h3>Section</h3> {/* Skipped h2! */}
// GOOD: Sequential hierarchy
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
色彩对比
WCAG 2.1 AA 要求:
- 4.5:1 普通文本对比度(低于 18pt / 14pt 粗体)
- 3:1 大文本对比度(18pt+ / 14pt+ 粗体)
- 3:1 用于 UI 组件和图形对象(按钮、图标、输入边框)
// Tailwind color contrast examples
// FAIL: gray-400 on white (#9ca3af on #fff = 2.8:1)
<p className="text-gray-400">This fails AA</p>
// PASS: gray-700 on white (#374151 on #fff = 10.7:1)
<p className="text-gray-700">This passes AA</p>
// For dark mode, test both themes separately
<p className="text-gray-700 dark:text-gray-300">
gray-700 on white (10.7:1) / gray-300 on gray-900 (9.2:1)
</p>
在开发过程中使用 WebAIM Contrast Checker 或浏览器 DevTools 对比工具。将其添加到您的故事书或设计令牌系统中以捕获回归:
// contrast-checker.ts
import { getContrast } from 'polished';
function assertContrast(fg: string, bg: string, level: 'AA' | 'AAA' = 'AA') {
const ratio = getContrast(fg, bg);
const required = level === 'AA' ? 4.5 : 7;
if (ratio < required) {
throw new Error(
`Contrast ratio ${ratio.toFixed(2)}:1 fails WCAG ${level} (requires ${required}:1)`
);
}
}
键盘导航
每个交互元素——链接、按钮、表单字段、自定义小部件——都必须可以通过键盘访问和操作。
焦点管理
// Skip link: first element on every page
// Allows keyboard users to jump past navigation
export function SkipLink() {
return (
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4
focus:z-50 focus:px-4 focus:py-2 focus:bg-blue-600 focus:text-white
focus:rounded focus:ring-2 focus:ring-white"
>
Skip to main content
</a>
);
}
// Main content target
<main id="main-content" tabIndex={-1}>
{/* tabIndex={-1} allows programmatic focus without appearing in tab order */}
模态框中的焦点陷印
当对话框打开时,焦点必须位于其中。关闭时,焦点返回到触发器:
// focus-trap.tsx using @radix-ui/react-focus-trap (used internally by shadcn Dialog)
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useRef } from 'react';
export function AccessibleModal({ trigger, children, title }: Props) {
const triggerRef = useRef<HTMLButtonElement>(null);
return (
<Dialog>
<DialogTrigger ref={triggerRef} asChild>
<button>Open</button>
</DialogTrigger>
<DialogContent
// shadcn Dialog handles focus trap and returns focus to trigger on close
aria-describedby="dialog-description"
>
<DialogTitle>{title}</DialogTitle>
<p id="dialog-description" className="sr-only">
{/* Screen reader description of dialog purpose */}
</p>
{children}
</DialogContent>
</Dialog>
);
}
可见焦点指示器
WCAG 2.1 SC 2.4.11(WCAG 2.2 中的 AA)需要最小 2 像素的焦点轮廓。切勿在没有更换的情况下抑制焦点:
/* globals.css */
/* NEVER do this: */
:focus { outline: none; }
/* DO this: custom, visible focus ring */
:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
border-radius: 4px;
}
/* Remove for mouse users (only show for keyboard) */
:focus:not(:focus-visible) {
outline: none;
}
ARIA:何时以及如何使用它
ARIA(可访问的富互联网应用程序)属性修改辅助技术解释 DOM 的方式。 ARIA 的第一条规则:如果您的用例存在本机 HTML 元素,请勿使用它。
ARIA 标签
// Icon-only button — screen reader has nothing to announce without aria-label
<button aria-label="Close dialog">
<X className="h-4 w-4" aria-hidden="true" />
</button>
// Form field with visible label — use htmlFor, not aria-label
<label htmlFor="email">Email address</label>
<input id="email" type="email" />
// Input with visible description
<input
id="password"
type="password"
aria-describedby="password-requirements"
/>
<p id="password-requirements">Must be at least 12 characters.</p>
ARIA 直播区域
在不移动焦点的情况下宣布动态内容变化:
// Status messages (search results count, form submission status)
function SearchResults({ count, loading }: Props) {
return (
<>
{/* aria-live="polite" waits for user to finish current action */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{loading ? 'Loading results...' : `${count} results found`}
</div>
{/* Visual result count (not for screen readers — aria-hidden) */}
<span aria-hidden="true">{count} results</span>
</>
);
}
// Error messages (aria-live="assertive" interrupts immediately)
function FormError({ error }: { error: string | null }) {
return (
<div
role="alert"
aria-live="assertive"
className={cn('text-red-500 text-sm', !error && 'hidden')}
>
{error}
</div>
);
}
用于自定义小部件的 ARIA
当您必须构建自定义小部件(选项卡面板、树视图、组合框)时,请严格遵循 ARIA 创作实践指南 (APG) 模式:
// Accessible tabs (ARIA tab pattern)
export function Tabs({ items }: { items: Tab[] }) {
const [active, setActive] = useState(0);
return (
<div>
<div role="tablist" aria-label="Content tabs">
{items.map((item, i) => (
<button
key={item.id}
role="tab"
aria-selected={active === i}
aria-controls={`panel-${item.id}`}
id={`tab-${item.id}`}
tabIndex={active === i ? 0 : -1} // Roving tabindex
onClick={() => setActive(i)}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') setActive((active + 1) % items.length);
if (e.key === 'ArrowLeft') setActive((active - 1 + items.length) % items.length);
}}
>
{item.label}
</button>
))}
</div>
{items.map((item, i) => (
<div
key={item.id}
role="tabpanel"
id={`panel-${item.id}`}
aria-labelledby={`tab-${item.id}`}
hidden={active !== i}
>
{item.content}
</div>
))}
</div>
);
}
表单和错误处理
无障碍表单是对认知和运动障碍用户影响最大的改进之一。
// Accessible form field with error state
function TextField({
id,
label,
error,
required,
hint,
...props
}: TextFieldProps) {
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;
const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined;
return (
<div>
<label htmlFor={id} className="font-medium">
{label}
{required && <span aria-hidden="true" className="text-red-500 ml-1">*</span>}
{required && <span className="sr-only">(required)</span>}
</label>
{hint && (
<p id={hintId} className="text-sm text-gray-500 mt-1">
{hint}
</p>
)}
<input
id={id}
aria-required={required}
aria-invalid={!!error}
aria-describedby={describedBy}
className={cn('input', error && 'border-red-500')}
{...props}
/>
{error && (
<p id={errorId} className="text-sm text-red-500 mt-1" role="alert">
{error}
</p>
)}
</div>
);
}
图像和媒体
// Informative image
<img src="/chart.png" alt="Bar chart showing 40% revenue growth in Q4 2025" />
// Decorative image — empty alt hides it from screen readers
<img src="/divider.png" alt="" role="presentation" />
// Complex image — use aria-describedby for long descriptions
<figure>
<img
src="/architecture.png"
alt="System architecture diagram"
aria-describedby="arch-desc"
/>
<figcaption id="arch-desc">
The diagram shows three tiers: frontend Next.js on port 3000,
NestJS API on port 3001, and PostgreSQL database on port 5433.
Redis sits between the API and database layers.
</figcaption>
</figure>
// SVG icons used as decoration
<svg aria-hidden="true" focusable="false">
<use href="#icon-search" />
</svg>
使用 axe-core 进行自动化测试
pnpm add -D @axe-core/playwright axe-core
// tests/a11y/homepage.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Homepage accessibility', () => {
test('should have no WCAG 2.1 AA violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.exclude('#third-party-widget') // Exclude known external violations
.analyze();
expect(results.violations).toEqual([]);
});
test('should be keyboard navigable', async ({ page }) => {
await page.goto('/');
// Tab through interactive elements and verify focus is visible
await page.keyboard.press('Tab');
const focusedElement = await page.evaluate(
() => document.activeElement?.getAttribute('href')
);
expect(focusedElement).toBe('#main-content'); // Skip link
});
});
添加到 CI 管道:
# .github/workflows/ci.yml
- name: Run accessibility tests
run: cd apps/web && npx playwright test tests/a11y --reporter=html
- uses: actions/upload-artifact@v4
with:
name: a11y-report
path: apps/web/playwright-report/
常见问题
WCAG 2.1 A、AA 和 AAA 之间有什么区别?
A 级是最低级别 — 未通过 A 级意味着某些用户从根本上无法访问该内容。 AA 级是大多数司法管辖区的法律标准,针对最广泛的用户需求。 AAA 级是一个理想的级别 - 并非所有内容类型都满足某些标准。将 AA 合规性作为您的基准,并在可行的情况下以 AAA 为目标。
使用像 shadcn/ui 这样的组件库能让我的应用程序易于访问吗?
shadcn/ui 基于 Radix UI 原语构建,这些原语可通过设计进行访问 - 它们包括正确的 ARIA 角色、键盘导航和焦点管理。但是,您仍然需要添加有意义的标签,可访问地处理错误状态,确保与自定义主题有足够的颜色对比度,并使用真正的辅助技术进行测试。组件库减轻了负担,但并没有消除可访问性测试的需要。
如何使用屏幕阅读器进行测试?
在 Windows 上,将 NVDA(免费)与 Firefox 或 Chrome 结合使用。在 macOS 上,将 VoiceOver(内置,Cmd+F5)与 Safari 结合使用。在移动设备上,使用 TalkBack (Android) 或 VoiceOver (iOS)。测试关键用户旅程:表单填写、模式交互、地标导航以及阅读动态内容。屏幕阅读器测试可以捕获自动化工具遗漏的公告、阅读顺序和焦点行为。
什么是流动 tabindex 模式?
Roving tabindex 是复合小部件(选项卡列表、工具栏、单选组、树视图)的键盘模式。组中一次只有一项具有 tabIndex={0} — 活动项。所有其他人都得到 tabIndex={-1}。箭头键在组内移动焦点并更新 tabIndex 0 的项目。这可以防止用户通过 Tab 键浏览组中的每个项目 - 他们使用 Tab 进入组,使用箭头键导航,然后使用 Tab 离开。
如何处理通过 AJAX 加载的动态内容的可访问性?
使用 aria-live 区域进行状态更新(搜索结果计数、保存确认)。对于整页部分替换,加载后将焦点移至新内容的标题或容器。对于加载状态,请在正在更新的区域上使用 aria-busy="true" ,并使用 aria-live="polite" 区域来宣布完成。请务必使用屏幕阅读器进行测试,以验证公告是否清晰且及时。
后续步骤
网络可访问性是一项持续的实践,而不是一次性审核。首先修复语义 HTML 和颜色对比度,然后为复杂的小部件分层键盘导航和 ARIA,并在 CI 管道中自动进行 WCAG 验证以捕获回归。
ECOSIRE 构建符合 WCAG 2.1 AA 的 Web 应用程序作为每个项目的基准标准。如果您需要进行可访问性审核或想要从头开始构建合规性,请探索我们的前端工程服务。
作者
ECOSIRE Research and Development Team
在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。
相关文章
Audit Preparation Checklist: Getting Your Books Ready
Complete audit preparation checklist covering financial statement readiness, supporting documentation, internal controls documentation, auditor PBC lists, and common audit findings.
Australian GST Guide for eCommerce Businesses
Complete Australian GST guide for eCommerce businesses covering ATO registration, the $75,000 threshold, low value imports, BAS lodgement, and GST for digital services.
Canadian HST/GST Guide: Province-by-Province
Complete Canadian HST/GST guide covering registration requirements, province-by-province rates, input tax credits, QST, place of supply rules, and CRA compliance.
更多来自Compliance & Regulation
Audit Preparation Checklist: Getting Your Books Ready
Complete audit preparation checklist covering financial statement readiness, supporting documentation, internal controls documentation, auditor PBC lists, and common audit findings.
Australian GST Guide for eCommerce Businesses
Complete Australian GST guide for eCommerce businesses covering ATO registration, the $75,000 threshold, low value imports, BAS lodgement, and GST for digital services.
Canadian HST/GST Guide: Province-by-Province
Complete Canadian HST/GST guide covering registration requirements, province-by-province rates, input tax credits, QST, place of supply rules, and CRA compliance.
Healthcare Accounting: Compliance and Financial Management
Complete guide to healthcare accounting covering HIPAA financial compliance, contractual adjustments, charity care, cost report preparation, and revenue cycle management.
India GST Compliance for Digital Businesses
Complete India GST compliance guide for digital businesses covering registration, GSTIN, rates, input tax credits, e-invoicing, GSTR returns, and TDS/TCS provisions.
Fund Accounting for Nonprofits: Best Practices
Master nonprofit fund accounting with net asset classifications, grant tracking, Form 990 preparation, functional expense allocation, and audit readiness best practices.