Web Accessibility in Practice: ARIA Roles and WCAG 2.2 Compliance Guide

前端工程(Updated Jun 17, 2026)

WCAG 2.2 Four Principles

WCAG 2.2 organizes compliance requirements around the POUR principles:

Principle Meaning Core Requirement Example
Perceivable Information is presentable Content perceivable by all users Image alt, captions, contrast
Operable Interface is operable Usable via multiple input methods Keyboard nav, sufficient time, no flashing
Understandable Content is comprehensible Content and operation are clear Consistent nav, error hints, input help
Robust Content is compatible Parseable by assistive technologies Valid HTML, ARIA semantics

ARIA Role System

Document Structure Roles

<header role="banner">
  <nav role="navigation" aria-label="Main navigation">
    <ul role="menubar">
      <li role="menuitem"><a href="/home">Home</a></li>
      <li role="menuitem" aria-haspopup="true">
        Products
        <ul role="menu">
          <li role="menuitem"><a href="/saas">SaaS</a></li>
          <li role="menuitem"><a href="/api">API</a></li>
        </ul>
      </li>
    </ul>
  </nav>
</header>
<main role="main">
  <article role="article">
    <h2>Article Title</h2>
    <p>Content body</p>
  </article>
</main>
<footer role="contentinfo">Copyright info</footer>

Widget Roles

Role Purpose Required Properties Key Interaction
button Clickable trigger None Enter/Space activate
checkbox Checked/unchecked aria-checked Space toggle
radio Single option aria-checked Arrow keys switch
slider Range value aria-valuenow, aria-valuemin, aria-valuemax Arrow keys adjust
tablist / tab Tab panels aria-selected Arrow keys switch
dialog Modal/non-modal aria-modal Esc close
combobox Selectable input aria-expanded Arrow select/type filter
tree / treeitem Tree structure aria-expanded Arrow expand/collapse

ARIA Properties and States

Core Properties Quick Reference

<!-- Labels and descriptions -->
<button aria-label="Close dialog">✕</button>
<input aria-labelledby="name-label" aria-describedby="name-hint">
<span id="name-label">Username</span>
<span id="name-hint">6-20 characters</span>

<!-- States -->
<div aria-expanded="true" aria-selected="false" aria-checked="mixed">
<div aria-hidden="true" aria-disabled="true" aria-busy="true">

<!-- Relationships -->
<div aria-owns="child-id" aria-controls="panel-id" aria-flowto="next-section">

<!-- Live regions -->
<div role="alert" aria-live="assertive">Error message</div>
<div aria-live="polite" aria-atomic="true" aria-relevant="additions text">Status update</div>

First Rule: Don't Overuse ARIA

If a native HTML element already provides the needed semantics and interaction, don't reimplement with ARIA.

<!-- ❌ Anti-pattern -->
<div role="button" tabindex="0" onclick="submit()">Submit</div>

<!-- ✅ Correct -->
<button type="submit">Submit</button>

Practice: Accessible Modal

<div class="modal-overlay" id="modalOverlay" hidden>
  <div
    role="dialog"
    aria-modal="true"
    aria-labelledby="modalTitle"
    aria-describedby="modalDesc"
    class="modal"
  >
    <h2 id="modalTitle">Confirm Deletion</h2>
    <p id="modalDesc">This action cannot be undone. Are you sure you want to delete this record?</p>
    <div class="modal-actions">
      <button class="btn-cancel">Cancel</button>
      <button class="btn-confirm">Confirm Delete</button>
    </div>
  </div>
</div>
class AccessibleModal {
  constructor(dialogEl) {
    this.dialog = dialogEl;
    this.previousFocus = null;
  }

  open() {
    this.previousFocus = document.activeElement;
    this.dialog.hidden = false;
    const firstFocusable = this.dialog.querySelector(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    firstFocusable?.focus();
    document.addEventListener('keydown', this.handleKeydown);
  }

  close() {
    this.dialog.hidden = true;
    this.previousFocus?.focus();
    document.removeEventListener('keydown', this.handleKeydown);
  }

  handleKeydown = (e) => {
    if (e.key === 'Escape') this.close();
    if (e.key === 'Tab') this.trapFocus(e);
  };

  trapFocus(e) {
    const focusable = [...this.dialog.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )];
    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();
    }
  }
}

Practice: Accessible Tabs

<div class="tabs">
  <div role="tablist" aria-label="Account settings">
    <button
      role="tab"
      id="tab-profile"
      aria-selected="true"
      aria-controls="panel-profile"
      tabindex="0"
    >Profile</button>
    <button
      role="tab"
      id="tab-security"
      aria-selected="false"
      aria-controls="panel-security"
      tabindex="-1"
    >Security</button>
    <button
      role="tab"
      id="tab-notify"
      aria-selected="false"
      aria-controls="panel-notify"
      tabindex="-1"
    >Notifications</button>
  </div>

  <div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">
    <p>Profile settings content</p>
  </div>
  <div role="tabpanel" id="panel-security" aria-labelledby="tab-security" hidden>
    <p>Security settings content</p>
  </div>
  <div role="tabpanel" id="panel-notify" aria-labelledby="tab-notify" hidden>
    <p>Notification preferences content</p>
  </div>
</div>
document.querySelector('[role="tablist"]').addEventListener('keydown', (e) => {
  const tabs = [...e.currentTarget.querySelectorAll('[role="tab"]')];
  const idx = tabs.indexOf(document.activeElement);
  let nextIdx;

  if (e.key === 'ArrowRight') nextIdx = (idx + 1) % tabs.length;
  else if (e.key === 'ArrowLeft') nextIdx = (idx - 1 + tabs.length) % tabs.length;
  else if (e.key === 'Home') nextIdx = 0;
  else if (e.key === 'End') nextIdx = tabs.length - 1;
  else return;

  e.preventDefault();
  tabs.forEach((t) => { t.setAttribute('aria-selected', 'false'); t.tabIndex = -1; });
  tabs[nextIdx].setAttribute('aria-selected', 'true');
  tabs[nextIdx].tabIndex = 0;
  tabs[nextIdx].focus();
});

Practice: Accessible Form

<form aria-label="User registration">
  <div class="field">
    <label for="email">Email address <span aria-hidden="true">*</span></label>
    <input
      id="email"
      type="email"
      required
      aria-required="true"
      autocomplete="email"
      aria-invalid="false"
      aria-describedby="email-error"
    >
    <span id="email-error" role="alert" aria-live="assertive"></span>
  </div>

  <div class="field">
    <label for="password">Password</label>
    <input
      id="password"
      type="password"
      minlength="8"
      aria-describedby="pwd-rules pwd-strength"
    >
    <span id="pwd-rules">At least 8 characters, including uppercase, lowercase, and digits</span>
    <span id="pwd-strength" aria-live="polite"></span>
  </div>

  <fieldset>
    <legend>Notification method</legend>
    <label><input type="checkbox" name="notify" value="email"> Email</label>
    <label><input type="checkbox" name="notify" value="sms"> SMS</label>
  </fieldset>

  <button type="submit">Register</button>
</form>

Automated Testing Tools Comparison

Tool Type WCAG Coverage Integration Feature
axe-core Runtime library A/AA CI + Browser Most comprehensive, zero false positives
Lighthouse Audit tool A CLI / DevTools Combined performance + a11y
WAVE Browser extension A/AA Manual Visual annotations
NVDA Screen reader Manual Real user experience
Playwright a11y E2E test A CI Integrated with test flow
import { test } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('accessibility check', async ({ page }) => {
  await page.goto('/register');
  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
    .analyze();
  expect(results.violations).toEqual([]);
});

WCAG 2.2 New Criteria

New Criterion Level Description
2.4.11 Focus Appearance AAA Focus indicator area ≥ 2px, contrast ≥ 3:1
2.4.12 Focus Not Obscured AAA Focused element not obscured by other content
3.2.6 Consistent Help A Help mechanism location consistent across pages
3.3.7 Redundant Entry A Don't require re-entering previously provided info
3.3.8 Accessible Authentication AA Authentication doesn't rely on cognitive function tests

Best Practices Summary

  1. Native elements first: Use <button>, <input> etc. instead of ARIA reimplementation
  2. Focus management: Proactively focus to logical position after modal/route changes
  3. Live regions with care: Use aria-live="assertive" only for critical notifications
  4. Automated + manual: axe-core covers ~30-40% of issues; screen reader testing is essential
  5. Continuous integration: Include accessibility checks in CI pipeline to prevent regressions

Try these browser-local tools — no sign-up required →

#无障碍#ARIA#WCAG#屏幕阅读器#可访问性