---
name: web-accessibility
description: "WCAG 2.2 AA web accessibility: ARIA roles/states, keyboard navigation, screen readers, color contrast, focus management, axe-core testing. Use when auditing or fixing a web UI against WCAG: ARIA, keyboard access, screen readers, contrast."
tags: [accessibility, a11y, wcag, aria, screen-reader, frontend]
version: "2025.1"
---

# Web Accessibility (WCAG 2.2 AA)

## Core Principles (POUR)

Web accessibility follows four principles: Perceivable, Operable, Understandable, Robust.
All production websites must meet WCAG 2.2 Level AA. This includes proper semantic HTML,
keyboard operability, screen reader support, and sufficient color contrast.

## Semantic HTML First

```html
<!-- Use native HTML elements before reaching for ARIA -->

<!-- Navigation -->
<nav aria-label="Main navigation">
  <ul>
    <li><a href="/" aria-current="page">Home</a></li>
    <li><a href="/products">Products</a></li>
    <li><a href="/about">About</a></li>
  </ul>
</nav>

<!-- Page structure -->
<header>
  <h1>Page Title</h1>
</header>
<main id="main-content">
  <article>
    <h2>Article Heading</h2>
    <p>Content...</p>
    <section aria-labelledby="comments-heading">
      <h3 id="comments-heading">Comments</h3>
    </section>
  </article>
  <aside aria-label="Related articles">
    <h2>Related</h2>
  </aside>
</main>
<footer>
  <p>&copy; 2025 Company</p>
</footer>

<!-- Skip link (first element in body) -->
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:bg-white focus:px-4 focus:py-2 focus:rounded">
  Skip to main content
</a>
```

## ARIA Roles, States, and Properties

```html
<!-- Dialog / Modal -->
<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc"
>
  <h2 id="dialog-title">Confirm Delete</h2>
  <p id="dialog-desc">This action cannot be undone.</p>
  <button>Cancel</button>
  <button>Delete</button>
</div>

<!-- Tabs -->
<div role="tablist" aria-label="Product info">
  <button role="tab" id="tab-1" aria-selected="true" aria-controls="panel-1">
    Description
  </button>
  <button role="tab" id="tab-2" aria-selected="false" aria-controls="panel-2" tabindex="-1">
    Reviews
  </button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
  <p>Product description...</p>
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
  <p>Product reviews...</p>
</div>

<!-- Live region for dynamic content -->
<div aria-live="polite" aria-atomic="true">
  <!-- Screen reader announces changes here -->
  <p>3 items in your cart</p>
</div>

<!-- Status messages -->
<div role="status" aria-live="polite">
  Form submitted successfully.
</div>
<div role="alert" aria-live="assertive">
  Error: Please correct the highlighted fields.
</div>

<!-- Loading state -->
<div aria-busy="true" aria-live="polite">
  Loading results...
</div>

<!-- Expandable content -->
<button aria-expanded="false" aria-controls="details-section">
  Show Details
</button>
<div id="details-section" hidden>
  <p>Additional information...</p>
</div>
```

## Form Accessibility

```html
<form aria-label="Contact form">
  <!-- Visible label (preferred) -->
  <div>
    <label for="name">Full Name <span aria-hidden="true">*</span></label>
    <input
      type="text"
      id="name"
      name="name"
      required
      aria-required="true"
      autocomplete="name"
    />
  </div>

  <!-- Error state -->
  <div>
    <label for="email">Email <span aria-hidden="true">*</span></label>
    <input
      type="email"
      id="email"
      name="email"
      required
      aria-required="true"
      aria-invalid="true"
      aria-describedby="email-error email-hint"
      autocomplete="email"
    />
    <p id="email-hint" class="hint">We'll never share your email.</p>
    <p id="email-error" class="error" role="alert">
      Please enter a valid email address.
    </p>
  </div>

  <!-- Fieldset for related inputs -->
  <fieldset>
    <legend>Preferred contact method</legend>
    <label>
      <input type="radio" name="contact" value="email" /> Email
    </label>
    <label>
      <input type="radio" name="contact" value="phone" /> Phone
    </label>
  </fieldset>

  <button type="submit">Send Message</button>
</form>
```

## Keyboard Navigation

```typescript
// Focus trap for modals
function trapFocus(element: HTMLElement) {
  const focusableSelectors = [
    'a[href]', 'button:not([disabled])', 'input:not([disabled])',
    'select:not([disabled])', 'textarea:not([disabled])',
    '[tabindex]:not([tabindex="-1"])',
  ];
  const focusableElements = element.querySelectorAll<HTMLElement>(
    focusableSelectors.join(',')
  );
  const firstFocusable = focusableElements[0];
  const lastFocusable = focusableElements[focusableElements.length - 1];

  element.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;

    if (e.shiftKey) {
      if (document.activeElement === firstFocusable) {
        e.preventDefault();
        lastFocusable.focus();
      }
    } else {
      if (document.activeElement === lastFocusable) {
        e.preventDefault();
        firstFocusable.focus();
      }
    }
  });

  firstFocusable?.focus();
}

// React: accessible keyboard handler
function handleKeyDown(event: React.KeyboardEvent, action: () => void) {
  if (event.key === 'Enter' || event.key === ' ') {
    event.preventDefault();
    action();
  }
}

// Roving tabindex for arrow key navigation (toolbar, menu, listbox)
function useRovingTabindex(items: HTMLElement[]) {
  let currentIndex = 0;

  items.forEach((item, index) => {
    item.tabIndex = index === 0 ? 0 : -1;
    item.addEventListener('keydown', (e) => {
      let newIndex = currentIndex;
      if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
        newIndex = (currentIndex + 1) % items.length;
      } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
        newIndex = (currentIndex - 1 + items.length) % items.length;
      } else if (e.key === 'Home') {
        newIndex = 0;
      } else if (e.key === 'End') {
        newIndex = items.length - 1;
      } else {
        return;
      }
      e.preventDefault();
      items[currentIndex].tabIndex = -1;
      items[newIndex].tabIndex = 0;
      items[newIndex].focus();
      currentIndex = newIndex;
    });
  });
}
```

## Color Contrast and Visual Design

```css
/* Minimum contrast ratios (WCAG 2.2 AA) */
/* Normal text (< 18pt / < 14pt bold): 4.5:1 */
/* Large text (>= 18pt / >= 14pt bold): 3:1 */
/* UI components and graphical objects: 3:1 */

/* Focus indicators must be visible */
:focus-visible {
  outline: 2px solid #4f46e5;
  outline-offset: 2px;
}

/* Do not use color alone to convey information */
.error-field {
  border-color: #dc2626;         /* Red border */
  border-width: 2px;             /* Thicker border */
  background: url('error.svg');  /* Error icon */
}

/* Ensure text remains readable over images */
.text-overlay {
  background: linear-gradient(to top, rgba(0,0,0,0.7), transparent);
  color: white;
}

/* Prefers reduced motion */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* Prefers high contrast */
@media (forced-colors: active) {
  .custom-checkbox {
    forced-color-adjust: none;
    /* Use system colors */
    border-color: ButtonText;
    background-color: Canvas;
  }
}
```

## Screen Reader Utilities

```css
/* Visually hidden but accessible to screen readers */
.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;
}

/* Make visible on focus (skip links) */
.sr-only-focusable:focus {
  position: static;
  width: auto;
  height: auto;
  padding: 0.5rem 1rem;
  margin: 0;
  overflow: visible;
  clip: auto;
  white-space: normal;
}
```

## Testing with axe-core

```typescript
// Vitest + axe-core
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

test('page has no accessibility violations', async () => {
  const { container } = render(<MyPage />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

// Playwright accessibility testing
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('homepage has no a11y violations', async ({ page }) => {
  await page.goto('/');
  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
    .analyze();
  expect(results.violations).toEqual([]);
});
```

## Do's

- Use semantic HTML elements (`button`, `nav`, `main`, `article`) before ARIA
- Provide visible focus indicators on all interactive elements
- Ensure all images have descriptive `alt` text (or `alt=""` for decorative images)
- Support keyboard navigation for every interactive element
- Use `aria-live` regions for dynamic content updates
- Test with actual screen readers (VoiceOver, NVDA, JAWS)
- Include skip links at the top of every page
- Respect `prefers-reduced-motion` and `prefers-color-scheme`

## Don'ts

- Do not use `div` or `span` as buttons; use `<button>` or `<a>`
- Do not rely on color alone to convey meaning (add icons, text, patterns)
- Do not remove focus outlines without providing an alternative
- Do not use `tabindex` > 0; it breaks natural tab order
- Do not auto-play media without user control
- Do not use ARIA when native HTML achieves the same result
- Do not hide content with `display: none` and expect screen readers to read it
- Do not use placeholder text as the only label for form fields

## Troubleshooting

| Problem | Cause | Solution |
|---------|-------|----------|
| Screen reader ignores element | `display: none` or `visibility: hidden` | Use `.sr-only` class to hide visually but keep accessible |
| Focus lost after DOM update | Focused element removed | Programmatically move focus to the next logical element |
| axe reports "color-contrast" | Text/background ratio too low | Use a contrast checker tool; adjust colors to meet 4.5:1 |
| Tab order is wrong | Unstructured DOM or tabindex misuse | Restructure DOM to match visual order; remove tabindex > 0 |
| Modal doesn't trap focus | No focus management | Implement focus trap and restore focus on close |
| Live region not announced | Missing `aria-live` or wrong timing | Add `aria-live="polite"` and inject content after region exists in DOM |
