accessibility-a11y

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Accessibility (a11y)

无障碍开发(a11y)

Overview

概述

This skill covers building accessible web applications that work for everyone, including people using screen readers, keyboard-only navigation, switch devices, and other assistive technologies. It addresses WCAG 2.2 compliance at AA and AAA levels, correct ARIA usage, focus management, color contrast, reduced motion support, and automated testing integration.
Use this skill when building new UI components, reviewing existing interfaces for accessibility compliance, fixing a11y audit findings, or integrating automated accessibility testing into CI/CD pipelines.

本技能涵盖构建面向所有人的无障碍Web应用,包括使用屏幕阅读器、纯键盘导航、切换设备及其他辅助技术的用户。内容涉及WCAG 2.2的AA和AAA级合规要求、正确的ARIA使用方法、焦点管理、颜色对比度、减少动画支持以及自动化测试集成。
在构建新UI组件、审查现有界面的无障碍合规性、修复a11y审计问题或在CI/CD流水线中集成无障碍自动化测试时,可使用本技能。

Core Principles

核心原则

  1. Semantic HTML first - Native HTML elements (
    <button>
    ,
    <nav>
    ,
    <dialog>
    ) provide accessibility for free. ARIA is a repair tool for when semantics are insufficient, not a replacement for proper HTML.
  2. Keyboard is the baseline - If it doesn't work with a keyboard alone, it doesn't work. Every interactive element must be focusable, operable, and have visible focus indicators.
  3. Test with real assistive technology - Automated tools catch ~30% of accessibility issues. The rest require manual testing with screen readers (VoiceOver, NVDA) and keyboard-only navigation.
  4. Progressive enhancement - Build the accessible version first, then layer on visual enhancements. Never hide content from assistive technology that sighted users can see.
  5. No information by color alone - Color can reinforce meaning but never be the sole indicator. Use icons, text labels, and patterns alongside color.

  1. 优先使用语义化HTML - 原生HTML元素(
    <button>
    <nav>
    <dialog>
    )自带无障碍支持。ARIA是语义不足时的修复工具,而非正确HTML的替代品。
  2. 键盘是基准 - 如果仅用键盘无法操作,那么它就是不可用的。每个交互元素必须可获取焦点、可操作,且有可见的焦点指示器。
  3. 使用真实辅助技术测试 - 自动化工具只能检测约30%的无障碍问题,其余问题需要通过屏幕阅读器(VoiceOver、NVDA)和纯键盘导航进行手动测试。
  4. 渐进式增强 - 先构建无障碍版本,再添加视觉增强效果。绝不要向辅助技术隐藏视力正常用户可见的内容。
  5. 不单独依赖颜色传递信息 - 颜色可强化含义,但绝不能作为唯一的指示符。需搭配图标、文本标签和图案一起使用。

Key Patterns

关键模式

Pattern 1: Accessible Modal Dialog

模式1:无障碍模态对话框

When to use: Any overlay that requires user interaction before returning to the main content.
Implementation:
tsx
import { useRef, useEffect, useCallback } from "react";

interface DialogProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

export function Dialog({ isOpen, onClose, title, children }: DialogProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (isOpen) {
      // Store the element that had focus before opening
      previousFocusRef.current = document.activeElement as HTMLElement;
      dialog.showModal();
    } else {
      dialog.close();
      // Restore focus to the triggering element
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  // Handle Escape key (native dialog handles this, but we need cleanup)
  const handleClose = useCallback(() => {
    onClose();
  }, [onClose]);

  // Handle backdrop click
  const handleBackdropClick = useCallback(
    (e: React.MouseEvent<HTMLDialogElement>) => {
      if (e.target === dialogRef.current) {
        onClose();
      }
    },
    [onClose]
  );

  if (!isOpen) return null;

  return (
    <dialog
      ref={dialogRef}
      onClose={handleClose}
      onClick={handleBackdropClick}
      aria-labelledby="dialog-title"
      aria-describedby="dialog-description"
      className="dialog"
    >
      <div className="dialog-content" role="document">
        <header className="dialog-header">
          <h2 id="dialog-title">{title}</h2>
          <button
            onClick={onClose}
            aria-label="Close dialog"
            className="dialog-close"
          >
            <span aria-hidden="true">&times;</span>
          </button>
        </header>
        <div id="dialog-description">{children}</div>
      </div>
    </dialog>
  );
}
css
/* Focus trap is handled natively by <dialog> showModal() */
dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

dialog .dialog-close:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
}
Why: The native
<dialog>
element with
showModal()
provides focus trapping, Escape key handling, and proper
role="dialog"
semantics automatically. Custom modal implementations almost always have focus trap bugs. Using the native element gives you correct behavior for free.

适用场景:任何需要用户交互后才能返回主内容的浮层。
实现代码
tsx
import { useRef, useEffect, useCallback } from "react";

interface DialogProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

export function Dialog({ isOpen, onClose, title, children }: DialogProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (isOpen) {
      // 记录打开前获取焦点的元素
      previousFocusRef.current = document.activeElement as HTMLElement;
      dialog.showModal();
    } else {
      dialog.close();
      // 将焦点恢复到触发元素
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  // 处理Esc键(原生dialog已处理,但需要清理逻辑)
  const handleClose = useCallback(() => {
    onClose();
  }, [onClose]);

  // 处理背景点击
  const handleBackdropClick = useCallback(
    (e: React.MouseEvent<HTMLDialogElement>) => {
      if (e.target === dialogRef.current) {
        onClose();
      }
    },
    [onClose]
  );

  if (!isOpen) return null;

  return (
    <dialog
      ref={dialogRef}
      onClose={handleClose}
      onClick={handleBackdropClick}
      aria-labelledby="dialog-title"
      aria-describedby="dialog-description"
      className="dialog"
    >
      <div className="dialog-content" role="document">
        <header className="dialog-header">
          <h2 id="dialog-title">{title}</h2>
          <button
            onClick={onClose}
            aria-label="Close dialog"
            className="dialog-close"
          >
            <span aria-hidden="true">&times;</span>
          </button>
        </header>
        <div id="dialog-description">{children}</div>
      </div>
    </dialog>
  );
}
css
/* 焦点捕获由原生<dialog>的showModal()处理 */
dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

dialog .dialog-close:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
}
设计原因:带有
showModal()
的原生
<dialog>
元素自动提供焦点捕获、Esc键处理和正确的
role="dialog"
语义。自定义模态框实现几乎总会存在焦点捕获漏洞,使用原生元素可免费获得正确的行为。

Pattern 2: Accessible Form with Error Handling

模式2:带错误处理的无障碍表单

When to use: Any form that collects user input and validates it.
Implementation:
tsx
interface FormFieldProps {
  id: string;
  label: string;
  type?: string;
  required?: boolean;
  error?: string;
  description?: string;
  value: string;
  onChange: (value: string) => void;
}

function FormField({
  id,
  label,
  type = "text",
  required = false,
  error,
  description,
  value,
  onChange,
}: FormFieldProps) {
  const descriptionId = description ? `${id}-description` : undefined;
  const errorId = error ? `${id}-error` : undefined;

  // Build aria-describedby from available descriptions
  const describedBy = [descriptionId, errorId].filter(Boolean).join(" ") || undefined;

  return (
    <div className="form-field">
      <label htmlFor={id}>
        {label}
        {required && <span aria-hidden="true"> *</span>}
        {required && <span className="sr-only"> (required)</span>}
      </label>

      {description && (
        <p id={descriptionId} className="field-description">
          {description}
        </p>
      )}

      <input
        id={id}
        type={type}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        required={required}
        aria-invalid={error ? "true" : undefined}
        aria-describedby={describedBy}
        aria-required={required}
      />

      {error && (
        <p id={errorId} className="field-error" role="alert">
          <span aria-hidden="true">!</span> {error}
        </p>
      )}
    </div>
  );
}

// Form-level error summary for screen readers
function ErrorSummary({ errors }: { errors: Record<string, string> }) {
  const errorEntries = Object.entries(errors);
  if (errorEntries.length === 0) return null;

  return (
    <div role="alert" aria-labelledby="error-summary-title" className="error-summary">
      <h3 id="error-summary-title">
        {errorEntries.length} error{errorEntries.length > 1 ? "s" : ""} found
      </h3>
      <ul>
        {errorEntries.map(([field, message]) => (
          <li key={field}>
            <a href={`#${field}`}>{message}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}
css
/* Screen-reader 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;
}

.field-error {
  color: var(--color-error);
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

/* Never rely on color alone for errors - include icon */
.field-error::before {
  content: "";
  /* Error icon via background-image */
}

input[aria-invalid="true"] {
  border-color: var(--color-error);
  /* Also use a thicker border or icon, not just color */
  border-width: 2px;
}
Why: Forms are the most common source of accessibility failures. This pattern ensures every field has a programmatic label, errors are announced via
role="alert"
, error messages are linked to inputs via
aria-describedby
, and the error summary lets keyboard users jump directly to problematic fields.

适用场景:任何收集用户输入并进行验证的表单。
实现代码
tsx
interface FormFieldProps {
  id: string;
  label: string;
  type?: string;
  required?: boolean;
  error?: string;
  description?: string;
  value: string;
  onChange: (value: string) => void;
}

function FormField({
  id,
  label,
  type = "text",
  required = false,
  error,
  description,
  value,
  onChange,
}: FormFieldProps) {
  const descriptionId = description ? `${id}-description` : undefined;
  const errorId = error ? `${id}-error` : undefined;

  // 从可用描述中构建aria-describedby
  const describedBy = [descriptionId, errorId].filter(Boolean).join(" ") || undefined;

  return (
    <div className="form-field">
      <label htmlFor={id}>
        {label}
        {required && <span aria-hidden="true"> *</span>}
        {required && <span className="sr-only"> (required)</span>}
      </label>

      {description && (
        <p id={descriptionId} className="field-description">
          {description}
        </p>
      )}

      <input
        id={id}
        type={type}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        required={required}
        aria-invalid={error ? "true" : undefined}
        aria-describedby={describedBy}
        aria-required={required}
      />

      {error && (
        <p id={errorId} className="field-error" role="alert">
          <span aria-hidden="true">!</span> {error}
        </p>
      )}
    </div>
  );
}

// 面向屏幕阅读器的表单级错误摘要
function ErrorSummary({ errors }: { errors: Record<string, string> }) {
  const errorEntries = Object.entries(errors);
  if (errorEntries.length === 0) return null;

  return (
    <div role="alert" aria-labelledby="error-summary-title" className="error-summary">
      <h3 id="error-summary-title">
        {errorEntries.length} error{errorEntries.length > 1 ? "s" : ""} found
      </h3>
      <ul>
        {errorEntries.map(([field, message]) => (
          <li key={field}>
            <a href={`#${field}`}>{message}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}
css
/* 仅屏幕阅读器可见的类 */
.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;
}

.field-error {
  color: var(--color-error);
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

// 绝不要仅依赖颜色表示错误 - 需包含图标
.field-error::before {
  content: "";
  /* 通过background-image设置错误图标 */
}

input[aria-invalid="true"] {
  border-color: var(--color-error);
  /* 除颜色外,还可使用更粗的边框或图标 */
  border-width: 2px;
}
设计原因:表单是无障碍问题最常见的来源。此模式确保每个字段都有程序化标签,错误信息通过
role="alert"
通知,错误消息通过
aria-describedby
与输入框关联,错误摘要允许键盘用户直接跳转到有问题的字段。

Pattern 3: Keyboard Navigation for Custom Widgets

模式3:自定义组件的键盘导航

When to use: Building custom interactive components (tabs, menus, listboxes, comboboxes) that don't map to native HTML elements.
Implementation:
tsx
// Accessible tabs following WAI-ARIA Authoring Practices
interface Tab {
  id: string;
  label: string;
  content: React.ReactNode;
}

function Tabs({ tabs }: { tabs: Tab[] }) {
  const [activeIndex, setActiveIndex] = useState(0);

  const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
    let newIndex = index;

    switch (e.key) {
      case "ArrowRight":
        newIndex = (index + 1) % tabs.length;
        break;
      case "ArrowLeft":
        newIndex = (index - 1 + tabs.length) % tabs.length;
        break;
      case "Home":
        newIndex = 0;
        break;
      case "End":
        newIndex = tabs.length - 1;
        break;
      default:
        return; // Don't prevent default for other keys
    }

    e.preventDefault();
    setActiveIndex(newIndex);

    // Move focus to the newly active tab
    const tabElement = document.getElementById(`tab-${tabs[newIndex].id}`);
    tabElement?.focus();
  };

  return (
    <div>
      <div role="tablist" aria-label="Content sections">
        {tabs.map((tab, index) => (
          <button
            key={tab.id}
            id={`tab-${tab.id}`}
            role="tab"
            aria-selected={index === activeIndex}
            aria-controls={`panel-${tab.id}`}
            tabIndex={index === activeIndex ? 0 : -1}
            onClick={() => setActiveIndex(index)}
            onKeyDown={(e) => handleKeyDown(e, index)}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab, index) => (
        <div
          key={tab.id}
          id={`panel-${tab.id}`}
          role="tabpanel"
          aria-labelledby={`tab-${tab.id}`}
          hidden={index !== activeIndex}
          tabIndex={0}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}
Why: Custom widgets must implement the expected keyboard interaction pattern from WAI-ARIA Authoring Practices. Tabs use Arrow keys to move between tabs (not Tab key), with
tabIndex={-1}
on inactive tabs so only the active tab is in the tab order. This matches the mental model of screen reader users.

适用场景:构建无法映射到原生HTML元素的自定义交互组件(标签页、菜单、列表框、组合框)。
实现代码
tsx
// 遵循WAI-ARIA创作实践的无障碍标签页
interface Tab {
  id: string;
  label: string;
  content: React.ReactNode;
}

function Tabs({ tabs }: { tabs: Tab[] }) {
  const [activeIndex, setActiveIndex] = useState(0);

  const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
    let newIndex = index;

    switch (e.key) {
      case "ArrowRight":
        newIndex = (index + 1) % tabs.length;
        break;
      case "ArrowLeft":
        newIndex = (index - 1 + tabs.length) % tabs.length;
        break;
      case "Home":
        newIndex = 0;
        break;
      case "End":
        newIndex = tabs.length - 1;
        break;
      default:
        return; // 不对其他按键阻止默认行为
    }

    e.preventDefault();
    setActiveIndex(newIndex);

    // 将焦点移动到新激活的标签页
    const tabElement = document.getElementById(`tab-${tabs[newIndex].id}`);
    tabElement?.focus();
  };

  return (
    <div>
      <div role="tablist" aria-label="内容区域">
        {tabs.map((tab, index) => (
          <button
            key={tab.id}
            id={`tab-${tab.id}`}
            role="tab"
            aria-selected={index === activeIndex}
            aria-controls={`panel-${tab.id}`}
            tabIndex={index === activeIndex ? 0 : -1}
            onClick={() => setActiveIndex(index)}
            onKeyDown={(e) => handleKeyDown(e, index)}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab, index) => (
        <div
          key={tab.id}
          id={`panel-${tab.id}`}
          role="tabpanel"
          aria-labelledby={`tab-${tab.id}`}
          hidden={index !== activeIndex}
          tabIndex={0}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}
设计原因:自定义组件必须实现WAI-ARIA创作实践中规定的键盘交互模式。标签页使用方向键在标签间切换(而非Tab键),非激活标签页设置
tabIndex={-1}
,仅激活标签页在Tab顺序中。这符合屏幕阅读器用户的认知模型。

Pattern 4: Live Regions for Dynamic Content

模式4:动态内容的实时区域

When to use: When content updates without a page reload and screen reader users need to be informed (notifications, search results, loading states).
Implementation:
tsx
// Toast notification system with live regions
function ToastContainer({ toasts }: { toasts: Toast[] }) {
  return (
    <div
      aria-live="polite"
      aria-atomic="false"
      aria-relevant="additions"
      className="toast-container"
    >
      {toasts.map((toast) => (
        <div
          key={toast.id}
          role="status"
          className={`toast toast-${toast.type}`}
        >
          <span className="toast-icon" aria-hidden="true">
            {toast.type === "success" ? "check" : "warning"}
          </span>
          <span>{toast.message}</span>
          <button
            onClick={() => dismissToast(toast.id)}
            aria-label={`Dismiss: ${toast.message}`}
          >
            <span aria-hidden="true">&times;</span>
          </button>
        </div>
      ))}
    </div>
  );
}

// For urgent errors, use role="alert" (assertive)
function CriticalError({ message }: { message: string }) {
  return (
    <div role="alert" className="critical-error">
      {message}
    </div>
  );
}

// Search results count announcement
function SearchResults({ query, count }: { query: string; count: number }) {
  return (
    <>
      <div aria-live="polite" className="sr-only">
        {count} results found for "{query}"
      </div>
      {/* Visual results list */}
    </>
  );
}
Why: Screen readers don't monitor the DOM for visual changes. Live regions explicitly tell assistive technology to announce new content. Use
aria-live="polite"
for non-urgent updates (search results, toasts) and
role="alert"
for urgent messages (errors, session expiry).

适用场景:内容无需页面刷新即可更新,且需要通知屏幕阅读器用户的场景(通知、搜索结果、加载状态)。
实现代码
tsx
// 带实时区域的Toast通知系统
function ToastContainer({ toasts }: { toasts: Toast[] }) {
  return (
    <div
      aria-live="polite"
      aria-atomic="false"
      aria-relevant="additions"
      className="toast-container"
    >
      {toasts.map((toast) => (
        <div
          key={toast.id}
          role="status"
          className={`toast toast-${toast.type}`}
        >
          <span className="toast-icon" aria-hidden="true">
            {toast.type === "success" ? "check" : "warning"}
          </span>
          <span>{toast.message}</span>
          <button
            onClick={() => dismissToast(toast.id)}
            aria-label={`Dismiss: ${toast.message}`}
          >
            <span aria-hidden="true">&times;</span>
          </button>
        </div>
      ))}
    </div>
  );
}

// 紧急错误使用role="alert"(强制通知)
function CriticalError({ message }: { message: string }) {
  return (
    <div role="alert" className="critical-error">
      {message}
    </div>
  );
}

// 搜索结果数量通知
function SearchResults({ query, count }: { query: string; count: number }) {
  return (
    <>
      <div aria-live="polite" className="sr-only">
        为“{query}”找到{count}条结果
      </div>
      {/* 视觉化结果列表 */}
    </>
  );
}
设计原因:屏幕阅读器不会监控DOM的视觉变化。实时区域明确告知辅助技术要播报新内容。非紧急更新(搜索结果、Toast通知)使用
aria-live="polite"
,紧急消息(错误、会话过期)使用
role="alert"

Pattern 5: Automated Accessibility Testing

模式5:无障碍自动化测试

When to use: Every project, integrated into CI/CD and development workflow.
Implementation:
typescript
// Jest + axe-core for component testing
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";

expect.extend(toHaveNoViolations);

describe("LoginForm", () => {
  it("should have no accessibility violations", async () => {
    const { container } = render(<LoginForm />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it("should have no violations in error state", async () => {
    const { container } = render(<LoginForm />);

    // Trigger validation errors
    fireEvent.click(screen.getByRole("button", { name: /submit/i }));

    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});
typescript
// Playwright + axe for E2E accessibility testing
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test.describe("accessibility", () => {
  test("home page passes axe audit", async ({ page }) => {
    await page.goto("/");

    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
      .analyze();

    expect(results.violations).toEqual([]);
  });

  test("modal dialog passes axe audit when open", async ({ page }) => {
    await page.goto("/dashboard");
    await page.getByRole("button", { name: "Create project" }).click();

    const results = await new AxeBuilder({ page })
      .include(".dialog")
      .analyze();

    expect(results.violations).toEqual([]);
  });
});
yaml
undefined
适用场景:所有项目,需集成到CI/CD和开发工作流中。
实现代码
typescript
// Jest + axe-core 组件测试
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";

expect.extend(toHaveNoViolations);

describe("LoginForm", () => {
  it("应无无障碍违规", async () => {
    const { container } = render(<LoginForm />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it("错误状态下应无违规", async () => {
    const { container } = render(<LoginForm />);

    // 触发验证错误
    fireEvent.click(screen.getByRole("button", { name: /submit/i }));

    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});
typescript
// Playwright + axe 端到端无障碍测试
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test.describe("accessibility", () => {
  test("首页通过axe审计", async ({ page }) => {
    await page.goto("/");

    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
      .analyze();

    expect(results.violations).toEqual([]);
  });

  test("模态框打开时通过axe审计", async ({ page }) => {
    await page.goto("/dashboard");
    await page.getByRole("button", { name: "Create project" }).click();

    const results = await new AxeBuilder({ page })
      .include(".dialog")
      .analyze();

    expect(results.violations).toEqual([]);
  });
});
yaml
undefined

GitHub Actions CI integration

GitHub Actions CI集成

name: Accessibility Audit on: [pull_request] jobs: a11y: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npm ci - run: npm run build - name: Run axe accessibility tests run: npx playwright test --grep "accessibility" - name: Run pa11y on built pages run: | npx serve -s build -l 3000 & sleep 3 npx pa11y-ci --config .pa11yci.json

**Why:** Automated testing catches structural issues (missing labels, invalid ARIA, contrast failures) consistently and early. Catching 30% of issues automatically in CI is better than catching 0% and discovering problems after launch. Combine with manual testing for full coverage.

---
name: Accessibility Audit on: [pull_request] jobs: a11y: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npm ci - run: npm run build - name: Run axe accessibility tests run: npx playwright test --grep "accessibility" - name: Run pa11y on built pages run: | npx serve -s build -l 3000 & sleep 3 npx pa11y-ci --config .pa11yci.json

**设计原因**:自动化测试可持续且早期发现结构性问题(缺失标签、无效ARIA、对比度不足)。在CI中自动检测30%的问题,比上线后才发现问题要好得多。需结合手动测试实现全面覆盖。

---

Color Contrast Quick Reference

颜色对比度速查

Text SizeWCAG AAWCAG AAA
Normal text (< 18px / 14px bold)4.5:17:1
Large text (>= 18px / 14px bold)3:14.5:1
UI components & graphical objects3:13:1
Decorative / disabled elementsNo requirementNo requirement

文本尺寸WCAG AAWCAG AAA
常规文本(<18px / 14px粗体)4.5:17:1
大文本(≥18px / 14px粗体)3:14.5:1
UI组件与图形对象3:13:1
装饰性/禁用元素无要求无要求

ARIA Quick Reference

ARIA速查

NeedUse ThisNot This
Label for input
<label for="x">
or
aria-labelledby
placeholder
as only label
Describe input requirements
aria-describedby
pointing to hint text
Title attribute
Hide decorative content
aria-hidden="true"
display: none
(hides from everyone)
Mark required field
aria-required="true"
+
required
Only visual asterisk
Announce error
role="alert"
or
aria-live="assertive"
Just changing text color
Expandable section
aria-expanded="true/false"
Custom
data-open
attribute

需求正确用法错误用法
输入框标签
<label for="x">
aria-labelledby
仅用
placeholder
作为标签
描述输入要求
aria-describedby
指向提示文本
Title属性
隐藏装饰性内容
aria-hidden="true"
display: none
(对所有人隐藏)
标记必填字段
aria-required="true"
+
required
仅用视觉星号
通知错误
role="alert"
aria-live="assertive"
仅更改文本颜色
可展开区域
aria-expanded="true/false"
自定义
data-open
属性

Anti-Patterns

反模式

Anti-PatternWhy It's BadBetter Approach
<div onclick>
instead of
<button>
No keyboard access, no role, no focusUse
<button>
for clickable elements
Using ARIA where native HTML worksMore complex, more bugs
<nav>
not
<div role="navigation">
tabindex="5"
(positive values)
Breaks natural tab orderUse
tabindex="0"
or
tabindex="-1"
only
aria-label
that duplicates visible text
Screen reader hears it twiceUse
aria-label
only for non-visible labels
Hiding focus outlines (
outline: none
)
Keyboard users lose their placeStyle
:focus-visible
instead of removing
autofocus
on page load
Disorients screen reader usersLet users navigate naturally
Images of textCannot be resized, not translatableUse real text with CSS styling

反模式危害优化方案
使用
<div onclick>
而非
<button>
无键盘访问权限、无角色、无焦点可点击元素使用
<button>
原生HTML可用时仍使用ARIA更复杂、易出错使用
<nav>
而非
<div role="navigation">
tabindex="5"
(正值)
破坏自然Tab顺序仅使用
tabindex="0"
tabindex="-1"
aria-label
重复可见文本
屏幕阅读器重复播报仅对不可见标签使用
aria-label
隐藏焦点轮廓(
outline: none
键盘用户丢失当前位置样式化
:focus-visible
而非移除轮廓
页面加载时自动聚焦(
autofocus
使屏幕阅读器用户困惑让用户自然导航
文本图片无法调整大小、不可翻译使用真实文本搭配CSS样式

Checklist

检查清单

  • All interactive elements are keyboard accessible (Tab, Enter, Space, Escape, Arrow keys)
  • Focus indicators are visible on all focusable elements (
    :focus-visible
    )
  • All images have descriptive
    alt
    text (or
    alt=""
    for decorative)
  • Form inputs have associated
    <label>
    elements
  • Error messages are programmatically associated with inputs (
    aria-describedby
    )
  • Color contrast meets WCAG AA (4.5:1 normal text, 3:1 large text)
  • No information conveyed by color alone
  • prefers-reduced-motion
    media query respected for animations
  • Page has correct heading hierarchy (h1 > h2 > h3, no skips)
  • Language is declared (
    <html lang="en">
    )
  • axe-core tests passing in CI
  • Manual testing done with VoiceOver (macOS) or NVDA (Windows)

  • 所有交互元素支持键盘访问(Tab、Enter、Space、Escape、方向键)
  • 所有可聚焦元素有可见的焦点指示器(
    :focus-visible
  • 所有图片有描述性
    alt
    文本(装饰性图片用
    alt=""
  • 表单输入框关联
    <label>
    元素
  • 错误消息通过
    aria-describedby
    与输入框程序化关联
  • 颜色对比度符合WCAG AA标准(常规文本4.5:1,大文本3:1)
  • 不单独依赖颜色传递信息
  • 尊重
    prefers-reduced-motion
    媒体查询以控制动画
  • 页面有正确的标题层级(h1 > h2 > h3,无跳跃)
  • 声明页面语言(
    <html lang="en">
  • axe-core测试在CI中通过
  • 使用VoiceOver(macOS)或NVDA(Windows)完成手动测试

Related Resources

相关资源

  • Skills:
    performance-engineering
    (intersection of performance and a11y),
    growth-engineering
    (accessible onboarding)
  • Rules:
    docs/reference/checklists/ui-visual-changes.md
    (visual change accessibility checks)
  • Rules:
    docs/reference/stacks/react-typescript.md
    (React component patterns)
  • 技能
    performance-engineering
    (性能与无障碍的交叉领域)、
    growth-engineering
    (无障碍引导)
  • 规则
    docs/reference/checklists/ui-visual-changes.md
    (视觉变更无障碍检查)
  • 规则
    docs/reference/stacks/react-typescript.md
    (React组件模式)