
设计系统无障碍架构WCAG 合规的系统化方案大家好我是蔓蔓。在之前负责建设设计系统组件库时我意识到一个被团队长期忽略的问题34 个基础组件中能通过键盘完整操作的不足 10 个支持屏幕阅读器的更是寥寥无几。这次我花了两个月时间将 WCAG 2.1 AA 标准系统化地融入设计系统架构。今天和大家分享完整方案。一、被遗漏的用户群体无障碍不是可选功能根据 WebAIM 的百万页面分析报告超过 96% 的网站首页存在可检测的无障碍缺陷。而前端项目中的无障碍问题往往不是开发者有意为之而是组件在设计之初就缺乏无障碍支持的内置结构。常见缺失的清单在设计系统一级排查中我发现以下高频问题问题类型影响用户典型表现键盘焦点管理缺失键盘用户、运动障碍用户Modal 打开后焦点仍在背后元素上语义化标签不足屏幕阅读器用户div 代替 button无 aria-label颜色对比度不达标低视力用户、色弱用户灰色文字#999在白色背景上比率仅 2.8:1动态内容无通知屏幕阅读器用户Toast 弹出但未被读屏软件感知焦点顺序混乱键盘用户Tab 键导航不遵循视觉顺序二、系统性无障碍架构设计从原子到页面的四层防护单点的无障碍修复无法持续。核心思路是将无障碍需求内化为组件的基础能力而非事后修补。flowchart TB subgraph 设计层 T[Design Tokens] -- C1[颜色对比度验证] T -- C2[字号/间距基线] T -- C3[焦点指示器规范] end subgraph 原子组件层 B[Base Components] -- K[键盘交互引擎] B -- A[ARIA 属性工厂] B -- F[焦点管理器] end subgraph 组合组件层 CP[Composite Components] -- FM[焦点陷阱 Focus Trap] CP -- R[角色映射 Role Mapping] CP -- LA[Live Region 管理] end subgraph 应用层 APP[Application] -- RN[路由级焦点恢复] APP -- SK[跳转链接 Skip Link] APP -- LN[页面级 Landmark] end 设计层 -- 原子组件层 -- 组合组件层 -- 应用层第一层Design Token 的对比度约束// tokens/colors.ts — 自动拦截不符合 WCAG AA 的颜色配置 interface ColorToken { hex: string; contrastAgainst: string[]; // 预期背景色 } function validateWCAGAA(token: ColorToken): boolean { for (const bg of token.contrastAgainst) { const ratio getContrastRatio(token.hex, bg); // WCAG AA 要求普通文本 4.5:1大文本 3:1 if (ratio 4.5) { console.error( 颜色 ${token.hex} 与背景 ${bg} 对比度 ${ratio.toFixed(1)}:1不满足 AA 标准 ); return false; } } return true; }第二层原子组件的 ARIA 属性工厂每个原子组件必须声明其 ARIA 角色、属性和键盘交互模式// packages/aria/factory.ts interface AriaConfig { role: string; properties: Recordstring, string; keyboardPattern: KeyboardPattern; } type KeyboardPattern button | checkbox | listbox | dialog; const keyboardMap: RecordKeyboardPattern, Recordstring, () void { button: { Enter: () activate(), Space: () activate(), }, listbox: { ArrowUp: () focusPrevious(), ArrowDown: () focusNext(), Enter: () select(), }, }; function createAriaComponent( element: HTMLElement, config: AriaConfig ): AriaHandle { element.setAttribute(role, config.role); Object.entries(config.properties).forEach(([key, value]) { element.setAttribute(aria-${key}, value); }); const keyboard keyboardMap[config.keyboardPattern]; element.addEventListener(keydown, (e) { const handler keyboard[e.key]; if (handler) { e.preventDefault(); handler(); } }); return { updateProperties, destroy }; }三、焦点管理从单组件到全局协调焦点管理是无障碍架构中最容易实现但最容易被忽略的一环。Focus Trap 实现// packages/focus/trap.ts class FocusTrap { private container: HTMLElement; private previousFocus: HTMLElement | null; constructor(container: HTMLElement) { this.container container; this.previousFocus document.activeElement as HTMLElement; } activate(): void { const focusable this.getFocusableElements(); if (focusable.length 0) { // Fallback: 给容器本身添加 tabindex this.container.setAttribute(tabindex, -1); this.container.focus(); return; } focusable[0].focus(); this.container.addEventListener(keydown, this.handleKeyDown); } deactivate(): void { this.container.removeEventListener(keydown, this.handleKeyDown); this.previousFocus?.focus(); } private getFocusableElements(): HTMLElement[] { const selector a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex-1]); return Array.from( this.container.querySelectorAllHTMLElement(selector) ); } private handleKeyDown (e: KeyboardEvent): void { if (e.key ! Tab) return; const focusable this.getFocusableElements(); const first focusable[0]; const last focusable[focusable.length - 1]; if (e.shiftKey document.activeElement first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey document.activeElement last) { e.preventDefault(); first.focus(); } }; }Live Region 管理器// packages/aria/live-region.ts type Politeness polite | assertive; class LiveRegionManager { private regions new MapPoliteness, HTMLElement(); constructor() { [polite, assertive].forEach((politeness) { const el document.createElement(div); el.setAttribute(aria-live, politeness); el.setAttribute(aria-atomic, true); el.className sr-only; // 视觉隐藏但可被读屏访问 document.body.appendChild(el); this.regions.set(politeness as Politeness, el); }); } announce(message: string, politeness: Politeness polite): void { const region this.regions.get(politeness)!; // 先清空再填充确保读屏软件重新读取 region.textContent ; requestAnimationFrame(() { region.textContent message; }); } }踩坑FocusTrap 的 deactivate 遗漏与 Shadow DOM 穿透在实际项目中遇到过两个高频问题第一忘记调用 deactivate。Modal 关闭时如果没有显式调用trap.deactivate()焦点会永久被困在已关闭的容器内。正确的做法是在 Modal 的onClose回调中始终调用 deactivate并将 FocusTrap 实例绑定到 Modal 的生命周期// ✅ 正确绑定到 Modal 生命周期 function Modal({ isOpen, onClose, children }) { const trapRef useRefFocusTrap | null(null); const containerRef useRefHTMLDivElement(null); useEffect(() { if (isOpen containerRef.current) { trapRef.current new FocusTrap(containerRef.current); trapRef.current.activate(); } return () { // 组件卸载或 isOpen 变为 false 时必须 deactivate trapRef.current?.deactivate(); trapRef.current null; }; }, [isOpen]); // ... }第二Shadow DOM 内的 focusable 元素无法被querySelectorAll查到。如果组件使用了 Shadow DOM如 Web ComponentsFocusTrap的getFocusableElements()只搜索了 Light DOM导致焦点在 Shadow DOM 边界处无法循环。解决方案是使用element.shadowRoot?.querySelectorAll递归穿透 Shadow DOM或在 Shadow DOM 容器中显式声明tabindex。3.1 焦点管理的测试策略在完成焦点管理实现后必须建立系统的测试策略。建议使用自动化测试工具如testing-library/user-event模拟键盘操作验证Tab键顺序、焦点陷阱、Escape键关闭等交互是否符合预期。同时应该在实际设备上测试而非仅依赖开发工具的模拟因为某些屏幕阅读器的行为在真实设备上会有差异。四、自动化检测与持续合规手动检查 34 个组件的无障碍状态是不可持续的。我们将检测规则集成到 CI 流程中。axe-core 自动化检测// test/a11y/runner.ts import { configureAxe, checkA11y } from axe-playwright; async function runA11yCheck(componentName: string) { const results await checkA11y(page, #component-root, { detailedReport: true, detailedReportOptions: { html: true }, }); const violations results.violations.filter( (v) v.impact critical || v.impact serious ); validateResults(componentName, violations); } function validateResults(component: string, violations: Violation[]) { // 严重违规直接阻断 CI const critical violations.filter((v) v.impact critical); if (critical.length 0) { throw new Error( ${component} 存在 ${critical.length} 个严重无障碍缺陷:\n critical.map((v) - ${v.help}: ${v.helpUrl}).join(\n) ); } }CI 集成与渐进式策略# .github/workflows/a11y.yml name: Accessibility Audit on: pull_request: paths: - packages/components/** jobs: a11y-check: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - name: Run Storybook run: npx storybook dev -p 6006 - name: Run accessibility tests run: npx playwright test tests/a11y/ - name: Report Block if: failure() run: | echo ::error::无障碍检测未通过请查看测试报告进行修复 exit 1五、总结将 WCAG 合规系统化融入设计系统架构需要从三个层面建立长效机制第一无障碍应当是组件的内置能力而非业务层的修补。通过 ARIA 属性工厂和键盘交互映射让每个组件在创建时就具备完整的无障碍支持。第二自动化检测是底线保障。axe-core 能覆盖约 57% 的 WCAG 问题其余 43% 需要人工审查如语义化结构、替代文本的质量等两者需要配合使用。第三合规是起点而非终点。WCAG AA 标准覆盖了视觉和操作层面的基本需求但真正优秀的无障碍体验还涉及认知障碍、临时性障碍等更广泛的场景。推荐实施路径先锁定使用频率最高的 5-10 个组件的无障碍修复建立自动检测流程再逐步覆盖全组件库。你的组件库是否支持无障碍访问遇到过哪些实际挑战欢迎在评论区交流