Accessibility isn't a feature or a nice-to-have. It's a legal requirement in most jurisdictions, and it affects far more users than you think. About 15-20% of the population has some form of disability. Add in temporary impairments (broken arm, bright sunlight, loud environment) and situational limitations (holding a baby, slow connection), and you're designing for the majority.
The Four Principles (POUR)
All of WCAG is organized under four principles:
| Principle | Meaning | Key Question |
|---|
| Perceivable | Users can perceive content through at least one sense | Can all users see, hear, or otherwise access this content? |
| Operable | Users can interact with all controls and navigation | Can all users navigate and use every interactive element? |
| Understandable | Users can understand content and how to use the interface | Is the content readable and the interface predictable? |
| Robust | Works across technologies including assistive tech | Does it work with screen readers, magnifiers, and future tools? |
| Level | Description | Legal Context |
|---|
| A | Minimum accessibility (25 criteria) | Not sufficient for compliance in most frameworks |
| AA | Standard compliance (13 additional criteria) | Required by most laws (ADA, EAA, Section 508) |
| AAA | Enhanced accessibility (23 additional criteria) | Aspirational. Rarely required, not always possible for all content |
Target AA. It's the legal standard and covers the vast majority of real-world accessibility needs.
Key Requirements: Detailed Checklist
Perceivable
| Requirement | How to Implement | Level |
|---|
| Images have alt text | <img alt="Description of what the image shows">. Decorative images: alt="" | A |
| Videos have captions | Synchronized captions for all pre-recorded video content | A |
| Videos have audio descriptions | Narrated descriptions of visual content for blind users | AA |
| Color is not the only indicator | Pair color with icons, text, or patterns. "Fields in red have errors" fails; use icons too. | A |
| Text contrast ≥ 4.5:1 | Body text on its background must meet 4.5:1 ratio | AA |
| Large text contrast ≥ 3:1 | Text 18pt+ (or 14pt+ bold) can have lower contrast | AA |
| UI component contrast ≥ 3:1 | Borders, icons, form controls against their background | AA |
| Text resizable to 200% | Content remains functional when browser zoom is 200% | AA |
| Content works in both orientations | Don't lock to portrait or landscape unless essential | AA |
| Spacing adjustable | Content works if line height is 1.5×, paragraph spacing 2×, letter spacing 0.12em, word spacing 0.16em | AA |
Operable
| Requirement | How to Implement | Level |
|---|
| All functionality via keyboard | Every interactive element reachable and usable with Tab, Enter, Space, Arrow keys | A |
| No keyboard traps | Users can always Tab away from any element. Modal dialogs must trap focus but provide an escape. | A |
| Skip links | First Tab stop: "Skip to main content" link | A |
| Focus order is logical | Tab order follows visual reading order (top-to-bottom, left-to-right) | A |
| Focus indicator visible | 2px+ outline visible on the currently focused element. Don't remove :focus styles. | AA |
| No flashing content | Nothing flashes more than 3 times per second | A |
| Motion can be paused | Auto-playing carousels, animations, and video must have pause controls | A |
| Touch targets ≥ 24×24px | Minimum target size for touch and click | AA |
| Touch targets ≥ 44×44px | Recommended target size | AAA |
| Dragging has alternatives | Every drag-and-drop interaction must have a non-drag alternative | AA |
Understandable
| Requirement | How to Implement | Level |
|---|
| Page language declared | <html lang="en"> on every page | A |
| Language changes marked | <span lang="fr">Bonjour</span> for inline foreign text | AA |
| Navigation consistent | Same navigation in the same order on all pages | AA |
| Labels consistent | Same action called by the same name everywhere | AA |
| Form inputs have visible labels | Every input has a <label>, not just placeholder text | A |
| Errors identified and described | Specific error messages, not just "Invalid input" | A |
| Error suggestions provided | Tell users how to fix the error | AA |
| Important actions reversible | Allow undo, or require confirmation for destructive actions | A |
| Help location consistent | Help/support link in the same position across pages | A |
| Redundant entry avoided | Don't ask for the same information twice in a process | A |
Robust
| Requirement | How to Implement | Level |
|---|
| Valid HTML | Use proper semantic elements, close tags, unique IDs | A |
| ARIA used correctly | Use ARIA only when native HTML can't achieve the pattern | A |
| Name, role, value accessible | All interactive elements expose their name, role, and state to assistive tech | A |
| Status messages announced | Use aria-live for dynamic content changes (toasts, counters, errors) | AA |
WCAG 2.2 New Criteria (2023)
These are the criteria added in WCAG 2.2 that you need to know:
| Criterion | Level | What It Requires | Practical Impact |
|---|
| Focus Not Obscured (Minimum) | AA | Focused element is at least partially visible (not completely hidden behind a sticky header or modal) | Check that sticky headers don't cover focused elements when tabbing |
| Focus Not Obscured (Enhanced) | AAA | Focused element is fully visible | Scroll elements into view on focus |
| Dragging Movements | AA | Alternative to drag-and-drop for all interactions | Provide up/down buttons for reordering, click-to-place for positioning |
| Target Size (Minimum) | AA | Interactive targets are at least 24×24px, or have sufficient spacing | Audit all small links, icons, and controls |
| Consistent Help | A | Help mechanism (chat, phone, FAQ link) appears in the same relative location on all pages | Standardize help link position |
| Redundant Entry | A | Don't ask users to re-enter information already provided in the same process | Auto-populate previously entered data |
| Accessible Authentication (Minimum) | A | Don't require cognitive function tests (memorizing a password, solving a CAPTCHA) unless alternatives exist | Support password managers, provide alternatives to CAPTCHA |
| Accessible Authentication (Enhanced) | AAA | No cognitive tests even as secondary verification | Use biometrics, email links, or passkeys |
ARIA: Practical Guide
The Rules of ARIA
- Don't use ARIA if native HTML works. A
<button> is better than <div role="button"> in every way. - Don't change native semantics unless you must. Don't add
role="heading" to a <button>. - All interactive ARIA controls must be keyboard-accessible. If you add
role="button", you must also handle Enter and Space key events. - Don't use
role="presentation" or aria-hidden="true" on focusable elements. You'll make things invisible to screen readers but still keyboard-focusable. - All interactive elements must have an accessible name. Via
aria-label, aria-labelledby, or visible text content.
Common ARIA Patterns
<!-- Landmark regions -->
<header role="banner">
<nav aria-label="Main navigation">
<main role="main">
<aside aria-label="Related articles">
<footer role="contentinfo">
<!-- When you have multiple navs, label them -->
<nav aria-label="Main navigation">...</nav>
<nav aria-label="Footer navigation">...</nav>
<!-- Live regions for dynamic content -->
<div aria-live="polite"> <!-- Non-urgent updates -->
Items in cart: 3
</div>
<div aria-live="assertive"> <!-- Urgent alerts -->
Error: Payment failed
</div>
<!-- Expandable content -->
<button aria-expanded="false" aria-controls="panel-1">
Show details
</button>
<div id="panel-1" hidden>
Panel content here
</div>
<!-- Tabs -->
<div role="tablist" aria-label="Account settings">
<button role="tab" aria-selected="true" aria-controls="panel-1">
Profile
</button>
<button role="tab" aria-selected="false" aria-controls="panel-2">
Security
</button>
</div>
<div role="tabpanel" id="panel-1">Profile content</div>
<div role="tabpanel" id="panel-2" hidden>Security content</div>
<!-- Modal dialog -->
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">Delete account?</h2>
<p>This action cannot be undone.</p>
<button>Cancel</button>
<button>Delete</button>
</div>
<!-- Form errors -->
<label for="email">Email address</label>
<input id="email"
type="email"
aria-describedby="email-error"
aria-invalid="true"
>
<span id="email-error" role="alert">
Please enter a valid email address
</span>
<!-- Loading state -->
<button aria-busy="true" aria-disabled="true">
<span class="spinner"></span>
Saving...
</button>
Semantic HTML Cheat Sheet
Prefer these over ARIA when possible:
| Instead of | Use |
|---|
<div role="button"> | <button> |
<div role="navigation"> | <nav> |
<div role="main"> | <main> |
<span role="link"> | <a href> |
<div role="heading" aria-level="2"> | <h2> |
<div role="list"> + <div role="listitem"> | <ul> + <li> |
<div role="img" aria-label="..."> | <img alt="..."> |
<div role="checkbox"> | <input type="checkbox"> |
Keyboard Navigation
Expected Keyboard Behaviors
| Key | Expected Action |
|---|
| Tab | Move to next focusable element |
| Shift+Tab | Move to previous focusable element |
| Enter | Activate links, buttons, submit forms |
| Space | Activate buttons, toggle checkboxes |
| Escape | Close modals, dropdowns, popovers |
| Arrow keys | Navigate within widgets (tabs, menus, radio groups, sliders) |
| Home/End | Jump to first/last item in a list or widget |
Focus Management
| Scenario | Focus Behavior |
|---|
| Modal opens | Focus moves to the first focusable element inside the modal |
| Modal closes | Focus returns to the element that triggered the modal |
| Content deleted | Focus moves to the next logical element (next item in list, or parent) |
| New page section loads | Focus moves to the new content's heading or first element |
| Error on form submit | Focus moves to the first field with an error |
Focus Trap for Modals
When a modal is open, Tab should cycle within the modal only. It shouldn't escape to the page behind it:
// Focus trap: cycle Tab within modal
function trapFocus(modal) {
const focusable = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
modal.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
if (e.key === 'Escape') {
closeModal();
}
});
}
Testing for Accessibility
Automated Testing (Catches ~30-40% of Issues)
| Tool | Type | What It Catches |
|---|
| axe DevTools | Browser extension | Missing alt text, contrast failures, ARIA errors |
| WAVE | Browser extension | Visual overlay of accessibility issues |
| Lighthouse | Chrome DevTools | Accessibility score + specific failures |
| Pa11y | CLI / CI integration | Automated testing in pipelines |
| eslint-plugin-jsx-a11y | Linter plugin | Catches issues during development |
Manual Testing Checklist
Do this for every major feature:
| Test | How | What You're Checking |
|---|
| Keyboard only | Unplug mouse, Tab through entire page | All functionality reachable, logical order, no traps |
| Screen reader | Use VoiceOver (Mac), NVDA (Windows), TalkBack (Android) | All content announced, forms labeled, states communicated |
| Zoom to 200% | Ctrl/Cmd + zoom browser to 200% | Content still readable, no horizontal scroll, nothing overlaps |
| Color blindness | Chrome DevTools → Rendering → Emulate vision deficiencies | Information not lost without color |
| Reduced motion | Enable reduced motion in OS settings | Animations reduced/eliminated, no motion sickness triggers |
| High contrast | Enable Windows High Contrast Mode | Content still visible, borders present, focus visible |
Screen Reader Testing Basics
Test these specific things:
| Check | Expected Behavior |
|---|
| Navigate by headings (H key) | Headings form a logical outline (H1 → H2 → H3, no skipping levels) |
| Navigate by landmarks | Main, nav, footer, and aside regions are announced |
| Form fields | Label announced when focusing each field |
| Buttons and links | Text content or aria-label announced |
| Images | Alt text announced (or properly hidden if decorative) |
| Dynamic content | Toast notifications, error messages, and updates announced |
| Tables | Headers associated with data cells |
Common Mistakes
| Mistake | Impact | Fix |
|---|
Removing :focus outlines | Keyboard users can't see where they are | Keep visible focus indicators. Style them, don't remove them. |
| Missing alt text | Screen reader users don't know what images show | Add descriptive alt text. Decorative images get alt="". |
| Color-only indicators | Colorblind users miss status information | Add icons, text, or patterns alongside color. |
| Div soup with ARIA overload | Fragile, often broken for screen readers | Use semantic HTML first. Only add ARIA when native HTML can't do it. |
| Modals without focus trap | Keyboard users Tab behind the modal into invisible content | Trap focus inside modal, return focus on close. |
| Auto-playing media | Disruptive, especially for screen reader users | Never autoplay with sound. Provide pause controls. |
| Missing form labels | Screen readers announce "edit text" with no context | Every input needs a <label for="id">. |
| Heading level skipping | Screen reader navigation by headings becomes confusing | Use H1 → H2 → H3 in order. Don't skip from H1 to H4. |
| Non-interactive elements made focusable | Confusing Tab order | Don't add tabindex="0" to divs and spans unless they're truly interactive. |
Accessibility Testing in CI/CD
# Run axe-core tests in your CI pipeline
npx @axe-core/cli https://your-site.com
# Use Pa11y for automated testing
npx pa11y https://your-site.com --standard WCAG2AA
# Lighthouse CI
npx lhci autorun --collect.url=https://your-site.com
Set a threshold: Fail the build if the accessibility score drops below a defined level (e.g., 90/100 on Lighthouse).
Key Takeaways
- Target WCAG 2.2 AA. It's the legal standard and covers real-world needs.
- Use semantic HTML first. Only reach for ARIA when native elements can't achieve the pattern.
- Test with keyboard, screen reader, zoom, and color blindness simulation. Automated tools catch less than half of issues.
- Color must never be the only indicator. Always pair with icons, text, or patterns.
- Every interactive element needs visible focus indicators, keyboard access, and an accessible name.
- Manage focus explicitly: move focus into modals, back on close, to errors on submit.
- Alt text: describe what the image shows, not what it is. "Team celebrating product launch" not "photo.jpg".
- Accessibility benefits everyone: screen readers, keyboard users, slow connections, bright sunlight, one-handed use.