可访问性不是您在发布后添加的功能 - 它是一个基本的质量属性,与性能或安全性相同。欧盟(《欧洲无障碍法案》,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 ERP、Shopify 电子商务、AI 智能体、Power BI 分析、GoHighLevel 自动化以及企业软件最佳实践。我们的指南帮助企业做出明智的技术决策。
相关文章
化工行业 ERP:安全、合规性和批量处理
ERP 系统如何管理化学品公司的 SDS 文件、REACH 和 GHS 合规性、批量处理、质量控制、危险品运输和配方管理。
适用于进出口贸易的 ERP:多货币、物流与合规性
ERP 系统如何为贸易公司处理信用证、海关文件、国际贸易术语解释通则、多币种损益表、集装箱跟踪和关税计算。
使用 ERP 进行可持续发展和 ESG 报告:2026 年合规指南
通过 ERP 系统在 2026 年实现 ESG 报告合规性。涵盖 CSRD、GRI、SASB、范围 1/2/3 排放、碳追踪和 Odoo 可持续性。
更多来自合规与监管
电子商务网络安全:2026 年保护您的业务
2026 年完整电子商务网络安全指南。PCI DSS 4.0、WAF 设置、机器人防护、支付欺诈预防、安全标头和事件响应。
化工行业 ERP:安全、合规性和批量处理
ERP 系统如何管理化学品公司的 SDS 文件、REACH 和 GHS 合规性、批量处理、质量控制、危险品运输和配方管理。
适用于进出口贸易的 ERP:多货币、物流与合规性
ERP 系统如何为贸易公司处理信用证、海关文件、国际贸易术语解释通则、多币种损益表、集装箱跟踪和关税计算。
使用 ERP 进行可持续发展和 ESG 报告:2026 年合规指南
通过 ERP 系统在 2026 年实现 ESG 报告合规性。涵盖 CSRD、GRI、SASB、范围 1/2/3 排放、碳追踪和 Odoo 可持续性。
审核准备清单:准备好您的书籍
完整的审计准备清单,涵盖财务报表准备情况、支持文件、内部控制文件、审计员 PBC 清单和常见审计结果。
澳大利亚电子商务企业商品及服务税指南
完整的澳大利亚电子商务企业 GST 指南,涵盖 ATO 注册、75,000 美元门槛、低值进口、BAS 申报和数字服务 GST。