Web Accessibility: WCAG 2.1 AA Compliance Guide

Build WCAG 2.1 AA compliant web apps with this practical guide. Covers semantic HTML, ARIA, keyboard navigation, color contrast, screen readers, and automated testing tools.

E
ECOSIRE Research and Development Team
|2026年3月19日6 分钟阅读1.3k 字数|

属于我们的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 应用程序作为每个项目的基准标准。如果您需要进行可访问性审核或想要从头开始构建合规性,请探索我们的前端工程服务

E

作者

ECOSIRE Research and Development Team

在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。

通过 WhatsApp 聊天