Accessibility is a legal requirement, a business consideration, and, most importantly, a matter of whether a significant portion of your users can actually use your product. Approximately 26% of adults in the US have some form of disability. Many use assistive technologies: screen readers, keyboard-only navigation, voice control, or high-contrast display modes. A site that doesn’t work with these tools excludes those users entirely.

WCAG (Web Content Accessibility Guidelines) 2.2 defines the standard. WCAG 2.2 AA compliance is what most accessibility regulations (ADA, Section 508, EU Web Accessibility Directive) require. For a broader overview of the business implications, see our accessibility fundamentals guide. If your business has already received legal notice, see our resource on handling an ADA website demand letter. This manual details the implementation specifics that developers need to achieve it.

The POUR Framework

WCAG is organized around four principles:

  • Perceivable: Content can be perceived by all users (alt text for images, captions for video)
  • Operable: All functionality works without a mouse (keyboard navigation, no seizure-inducing content)
  • Understandable: Content and UI are clear and consistent (descriptive labels, error messages)
  • Reliable: Content works with current and future assistive technologies (valid HTML, correct ARIA)

Semantic HTML First

Abstract visual representing semantic HTML structure, using layered translucent blocks, deep navy background, no text labels

The most impactful accessibility improvement is almost always using semantic HTML correctly. Many ARIA attributes are unnecessary when the right HTML element is used.

<!-- BAD: Div soup with no semantic meaning -->
<div class="button" onclick="submit()">Submit</div>
<div class="nav">
  <div class="nav-item">Home</div>
</div>

<!-- GOOD: Native HTML elements with built-in accessibility -->
<button type="submit">Submit</button>
<nav>
  <a href="/">Home</a>
</nav>

Native HTML elements come with built-in keyboard support, focus management, and screen reader announcements. A <button> is automatically focusable, activatable with Enter/Space, and announced as “button” by screen readers. A <div role="button"> requires you to implement all of this manually, and most implementations miss something.

Semantic HTML checklist:

  • Use <button> for actions, <a> for navigation
  • Use heading hierarchy (<h1><h2><h3>) without skipping levels
  • Use <nav>, <main>, <aside>, <header>, <footer> landmarks
  • Use <ul>/<ol>/<li> for lists
  • Use <table> with <th> and scope attribute for data tables
  • Use <label> with for attribute for all form inputs

Keyboard Navigation

Abstract visualization of keyboard focus pathways, glowing amber nodes connecting, deep navy background, no text labels

Every interactive element must be reachable and operable via keyboard. Tab moves forward through focusable elements; Shift+Tab moves backward. Enter activates links and buttons; Space activates buttons and checkboxes.

Focus indicators

WCAG 2.2 requires visible focus indicators with sufficient contrast. Never do this:

/* NEVER: Removes all focus indicators */
*:focus { outline: none; }

Instead, style focus indicators to match your design:

/* Accessible custom focus indicator */
:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
  border-radius: 2px;
}

WCAG 2.2 Focus Appearance (AA) requires the focus indicator to have:

  • Area of at least the perimeter of the unfocused component × 2px
  • Color contrast ratio of at least 3:1 against adjacent colors

Focus management in dynamic content

When modal dialogs open, menus appear, or page sections change, focus must be managed:

function openModal(modalId: string) {
  const modal = document.getElementById(modalId);
  if (!modal) return;

  modal.removeAttribute('hidden');

  const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
  const firstFocusable = modal.querySelector<HTMLElement>(focusableSelector);
  firstFocusable?.focus();

  modal.addEventListener('keydown', trapFocus);
}

function trapFocus(event: KeyboardEvent) {
  if (event.key !== 'Tab') return;

  const modal = event.currentTarget as HTMLElement;
  const focusableElements = modal.querySelectorAll<HTMLElement>(
    'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
  );

  const firstEl = focusableElements[0];
  const lastEl = focusableElements[focusableElements.length - 1];

  if (event.shiftKey && document.activeElement === firstEl) {
    lastEl.focus();
    event.preventDefault();
  } else if (!event.shiftKey && document.activeElement === lastEl) {
    firstEl.focus();
    event.preventDefault();
  }
}

function closeModal(modalId: string, triggerElement: HTMLElement) {
  const modal = document.getElementById(modalId);
  modal?.setAttribute('hidden', '');
  triggerElement.focus();
}

ARIA: When and How to Use It

ARIA (Accessible Rich Internet Applications) attributes add semantic meaning that HTML alone doesn’t provide. The first rule of ARIA: don’t use ARIA if a native HTML element or attribute can do the job.

Core ARIA patterns

Live regions: Announce dynamic content changes to screen readers:

<div role="status" aria-live="polite" aria-atomic="true" id="status-message">
  <!-- Content injected here is announced by screen readers -->
</div>

<div role="alert" aria-live="assertive">
  Payment failed. Please check your card details.
</div>

Disclosure widgets (expandable sections):

<button
  type="button"
  aria-expanded="false"
  aria-controls="faq-answer-1"
  id="faq-question-1"
>
  What is DMARC?
</button>
<div
  id="faq-answer-1"
  role="region"
  aria-labelledby="faq-question-1"
  hidden
>
  <p>DMARC is an email authentication protocol...</p>
</div>

Combobox (autocomplete/search):

<label for="search-input">Search products</label>
<input
  type="text"
  id="search-input"
  role="combobox"
  aria-autocomplete="list"
  aria-expanded="false"
  aria-controls="search-listbox"
  aria-activedescendant=""
  autocomplete="off"
/>
<ul id="search-listbox" role="listbox" hidden>
  <li role="option" id="option-1" aria-selected="false">Widget Pro</li>
  <li role="option" id="option-2" aria-selected="false">Widget Lite</li>
</ul>

Color Contrast

Abstract visual showing high contrast geometric divisions, sharp transitions from dark to light, deep navy and warm amber, no text labels

WCAG AA requires:

  • Normal text (under 18pt / 14pt bold): 4.5:1 contrast ratio against background
  • Large text (18pt+ / 14pt+ bold): 3:1 contrast ratio
  • UI components and graphics: 3:1 against adjacent colors
function getLuminance(r: number, g: number, b: number): number {
  const [rs, gs, bs] = [r, g, b].map(c => {
    c = c / 255;
    return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
  });
  return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}

function getContrastRatio(color1: [number,number,number], color2: [number,number,number]): number {
  const l1 = getLuminance(...color1);
  const l2 = getLuminance(...color2);
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);
  return (lighter + 0.05) / (darker + 0.05);
}

// Blue #2563eb on white: 4.6:1 → PASSES AA for normal text
const textOnBackground = getContrastRatio([37, 99, 235], [255, 255, 255]);

Use WebAIM Contrast Checker or the contrast checker built into browser DevTools for manual checks.

Images and Alt Text

<!-- Informative image: describe what the image conveys -->
<img src="bar-chart.png" alt="Monthly revenue increased from $42k in January to $68k in June 2026" />

<!-- Decorative image: empty alt tells screen readers to skip it -->
<img src="decorative-border.png" alt="" role="presentation" />

<!-- Functional image (link/button): describe the action -->
<a href="/cart">
  <img src="cart-icon.svg" alt="Shopping cart -- 3 items" />
</a>

<!-- Complex image with detailed description -->
<figure>
  <img src="system-diagram.png" alt="Architecture diagram" aria-describedby="diagram-desc" />
  <figcaption id="diagram-desc">
    System architecture showing three-tier structure: client browser → load balancer →
    Node.js API servers → PostgreSQL database with Redis cache
  </figcaption>
</figure>

Forms

Form accessibility is one of the highest-impact areas because forms are where users complete critical actions.

<div class="form-field">
  <label for="email">
    Email address
    <span aria-hidden="true">*</span>
  </label>
  <input
    type="email"
    id="email"
    name="email"
    required
    aria-required="true"
    aria-describedby="email-error email-hint"
    autocomplete="email"
  />
  <p id="email-hint" class="hint-text">We'll send your confirmation here</p>
  <p id="email-error" role="alert" hidden>
    Please enter a valid email address
  </p>
</div>
function showFieldError(fieldId: string, message: string) {
  const field = document.getElementById(fieldId) as HTMLInputElement;
  const errorEl = document.getElementById(`${fieldId}-error`);

  field?.setAttribute('aria-invalid', 'true');

  if (errorEl) {
    errorEl.textContent = message;
    errorEl.removeAttribute('hidden');
  }
}

Automated Testing

npm install -D axe-core @axe-core/playwright
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('homepage has no accessibility violations', async ({ page }) => {
  await page.goto('/');

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

  expect(results.violations).toHaveLength(0);
});

Automated tools catch approximately 30-40% of accessibility issues. While exploring AI tools for ADA and WCAG compliance can help scale your testing efforts, manual keyboard testing and screen reader testing (VoiceOver on macOS, NVDA on Windows, TalkBack on Android) are necessary to catch the rest. Good accessibility often correlates with good performance, positively affecting your Core Web Vitals and SEO.

Screen reader quick test (macOS VoiceOver):

  1. Press Cmd+F5 to start VoiceOver
  2. Tab through your page with keyboard
  3. Verify every interactive element is reachable and has a descriptive announcement
  4. Open a form and complete it with keyboard only

Accessibility is not a one-time audit, it’s an ongoing practice built into development workflow. The most effective teams run automated accessibility tests in CI (same as unit tests), include keyboard testing in PR review checklists, and treat accessibility violations as bugs, not nice-to-haves.