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

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>andscopeattribute for data tables - Use
<label>withforattribute for all form inputs
Keyboard Navigation

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

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):
- Press
Cmd+F5to start VoiceOver - Tab through your page with keyboard
- Verify every interactive element is reachable and has a descriptive announcement
- 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.



