Accessibility (a11y) Implementation

1. WCAG 2.1 Compliance (AA Level, Success Criteria)

Level Requirements Target Audience Legal Status
WCAG 2.1 A 30 criteria - basic accessibility Minimum for most disabilities Baseline legal requirement
WCAG 2.1 AA TARGET 50 criteria - enhanced accessibility Most users with disabilities Required by ADA, Section 508, EU
WCAG 2.1 AAA 78 criteria - highest accessibility Specialized needs Recommended but not required
WCAG 2.2 NEW 9 new criteria (mostly AA) Mobile, cognitive disabilities Emerging standard (2023)

Example: WCAG 2.1 AA Implementation

// 1.1.1 Non-text Content (A)
// All images must have alt text
<img src="logo.png" alt="Company Logo" />
<img src="decorative.png" alt="" /> {/* Decorative images */}

// 1.3.1 Info and Relationships (A)
// Use semantic HTML
<header>
  <nav>
    <ul>
      <li><a href="/">Home</a></li>
    </ul>
  </nav>
</header>
<main>
  <article>
    <h1>Page Title</h1>
    <section>
      <h2>Section Title</h2>
    </section>
  </article>
</main>

// 1.4.3 Contrast Minimum (AA)
// Minimum contrast ratio 4.5:1 for normal text, 3:1 for large text
.text {
  color: #333; /* On white background = 12.6:1 ratio ✅ */
  background: #fff;
}

.button {
  color: #fff;
  background: #0066cc; /* 4.5:1 ratio ✅ */
}

// 1.4.5 Images of Text (AA)
// Use actual text, not images of text (unless logo/brand)
<h1>Welcome</h1> {/* Good */}
<img src="welcome-text.png" alt="Welcome" /> {/* Avoid */}

// 1.4.10 Reflow (AA)
// Content reflows at 320px without horizontal scrolling
@media (max-width: 320px) {
  .content {
    width: 100%;
    overflow-x: hidden;
  }
}

// 1.4.11 Non-text Contrast (AA)
// UI components and graphical objects need 3:1 contrast
.button {
  border: 2px solid #767676; /* 3:1 contrast against white ✅ */
}

// 2.1.1 Keyboard (A)
// All functionality available via keyboard
<button onClick={handleClick} onKeyDown={handleKeyDown}>
  Click me
</button>

// Custom interactive elements
<div
  role="button"
  tabIndex={0}
  onClick={handleClick}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      handleClick();
    }
  }}
>
  Custom Button
</div>

// 2.1.2 No Keyboard Trap (A)
// Users can navigate away using keyboard only
function Modal({ onClose }) {
  useEffect(() => {
    const handleEscape = (e) => {
      if (e.key === 'Escape') onClose();
    };
    document.addEventListener('keydown', handleEscape);
    return () => document.removeEventListener('keydown', handleEscape);
  }, [onClose]);
  
  return (
    <div role="dialog" aria-modal="true">
      <button onClick={onClose}>Close</button>
      {/* Modal content */}
    </div>
  );
}

// 2.4.1 Bypass Blocks (A)
// Skip navigation link
<a href="#main-content" className="skip-link">
  Skip to main content
</a>

<main id="main-content">
  {/* Content */}
</main>

.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  padding: 8px;
  background: #000;
  color: #fff;
}

.skip-link:focus {
  top: 0;
}

// 2.4.3 Focus Order (A)
// Logical focus order using tabindex
<div>
  <button tabIndex={0}>First</button>
  <button tabIndex={0}>Second</button>
  <button tabIndex={0}>Third</button>
</div>

// 2.4.7 Focus Visible (AA)
// Visible focus indicator
button:focus {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

// 3.2.1 On Focus (A)
// No context change on focus alone
<input
  type="text"
  onFocus={() => console.log('Focused')} {/* OK */}
  onFocus={() => submitForm()} {/* BAD - unexpected */}
/>

// 3.2.2 On Input (A)
// No context change on input alone
<input
  type="text"
  onChange={(e) => setValue(e.target.value)} {/* OK */}
  onChange={() => navigateAway()} {/* BAD - unexpected */}
/>

// 3.3.1 Error Identification (A)
// Clearly identify input errors
<form onSubmit={handleSubmit}>
  <label htmlFor="email">Email</label>
  <input
    id="email"
    type="email"
    aria-invalid={!!errors.email}
    aria-describedby={errors.email ? 'email-error' : undefined}
  />
  {errors.email && (
    <span id="email-error" role="alert" className="error">
      {errors.email}
    </span>
  )}
</form>

// 3.3.2 Labels or Instructions (A)
// Label all form inputs
<label htmlFor="username">Username</label>
<input id="username" type="text" required />

// Or use aria-label
<input type="search" aria-label="Search articles" />

// 4.1.2 Name, Role, Value (A)
// Custom components need ARIA
<div
  role="checkbox"
  aria-checked={isChecked}
  aria-labelledby="checkbox-label"
  tabIndex={0}
  onClick={toggle}
  onKeyDown={(e) => {
    if (e.key === ' ') toggle();
  }}
>
  <span id="checkbox-label">Accept terms</span>
</div>

// React accessibility hook
function useA11yAnnouncement() {
  const [announcement, setAnnouncement] = useState('');
  
  return {
    announce: (message) => {
      setAnnouncement(message);
      setTimeout(() => setAnnouncement(''), 100);
    },
    LiveRegion: () => (
      <div
        role="status"
        aria-live="polite"
        aria-atomic="true"
        className="sr-only"
      >
        {announcement}
      </div>
    )
  };
}

// Screen reader only utility
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

// Accessible form validation
function AccessibleForm() {
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  
  return (
    <form noValidate>
      <div>
        <label htmlFor="email">
          Email <span aria-label="required">*</span>
        </label>
        <input
          id="email"
          type="email"
          required
          aria-required="true"
          aria-invalid={touched.email && !!errors.email}
          aria-describedby={
            touched.email && errors.email ? 'email-error' : undefined
          }
        />
        {touched.email && errors.email && (
          <span id="email-error" role="alert">
            {errors.email}
          </span>
        )}
      </div>
    </form>
  );
}

// WCAG 2.2 new criteria
// 2.4.11 Focus Not Obscured (Minimum) (AA)
// Ensure focused element is not fully hidden
.modal {
  z-index: 1000;
  /* Ensure focus is visible within modal */
}

// 3.2.6 Consistent Help (A)
// Help mechanism in same location on all pages
<header>
  <a href="/help" aria-label="Get help">Help</a>
</header>

WCAG 2.1 AA Checklist

  • ADA (US): WCAG 2.1 AA for websites
  • Section 508 (US): Federal sites must comply
  • EAA (EU): June 2025 deadline
  • AODA (Canada): WCAG 2.0 AA required
  • DDA (UK): Reasonable adjustments
  • Penalties: Lawsuits, fines up to $75,000
Statistics: 26% of US adults have a disability. 1 in 4 potential customers. Accessible sites have better SEO, mobile UX, and legal protection.

2. Screen Reader Testing (NVDA, JAWS, VoiceOver)

Screen Reader Platform Market Share Cost
JAWS Windows ~40% $1,000+ (most feature-rich)
NVDA Windows ~30% Free (open source)
VoiceOver macOS, iOS ~15% Free (built-in)
TalkBack Android ~10% Free (built-in)
Narrator Windows ~5% Free (built-in)

Example: Screen Reader Testing

// Screen reader keyboard shortcuts
// NVDA (Windows)
NVDA + Space           // Toggle browse/focus mode
NVDA + Down Arrow      // Read next item
NVDA + T               // Next table
NVDA + H               // Next heading
NVDA + K               // Next link
NVDA + B               // Next button
NVDA + F               // Next form field
NVDA + Insert + F7     // Elements list

// JAWS (Windows)
Insert + Down Arrow    // Say all (read from cursor)
Insert + F5            // Form fields list
Insert + F6            // Headings list
Insert + F7            // Links list
H                      // Next heading
T                      // Next table
B                      // Next button

// VoiceOver (macOS)
Cmd + F5               // Turn on/off VoiceOver
VO + A                 // Read all
VO + Right Arrow       // Next item
VO + U                 // Rotor (elements list)
VO + H + H             // Next heading
VO + J + J             // Next form control

// Optimizing for screen readers
// Accessible button
<button
  aria-label="Delete item"
  onClick={handleDelete}
>
  <TrashIcon aria-hidden="true" />
</button>

// Accessible icon button with text
<button onClick={handleSave}>
  <SaveIcon aria-hidden="true" />
  <span>Save</span>
</button>

// Hidden from screen readers
<div aria-hidden="true">
  Decorative content
</div>

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

// Multiple navigation landmarks
<nav aria-label="Primary navigation">...</nav>
<nav aria-label="Footer navigation">...</nav>

// Accessible table
<table>
  <caption>Monthly Sales Data</caption>
  <thead>
    <tr>
      <th scope="col">Month</th>
      <th scope="col">Sales</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">January</th>
      <td>$10,000</td>
    </tr>
  </tbody>
</table>

// Accessible form with fieldset
<form>
  <fieldset>
    <legend>Shipping Address</legend>
    <label htmlFor="street">Street</label>
    <input id="street" type="text" />
    
    <label htmlFor="city">City</label>
    <input id="city" type="text" />
  </fieldset>
</form>

// Accessible dialog/modal
function Modal({ isOpen, onClose, title, children }) {
  const titleId = useId();
  const descId = useId();
  
  useEffect(() => {
    if (isOpen) {
      // Trap focus
      const firstFocusable = modalRef.current.querySelector('button, a, input');
      firstFocusable?.focus();
    }
  }, [isOpen]);
  
  if (!isOpen) return null;
  
  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby={titleId}
      aria-describedby={descId}
    >
      <h2 id={titleId}>{title}</h2>
      <div id={descId}>{children}</div>
      <button onClick={onClose}>Close</button>
    </div>
  );
}

// Accessible tabs
function Tabs({ tabs, defaultTab }) {
  const [activeTab, setActiveTab] = useState(defaultTab);
  
  return (
    <div>
      <div role="tablist" aria-label="Content tabs">
        {tabs.map((tab) => (
          <button
            key={tab.id}
            role="tab"
            aria-selected={activeTab === tab.id}
            aria-controls={`panel-${tab.id}`}
            id={`tab-${tab.id}`}
            onClick={() => setActiveTab(tab.id)}
          >
            {tab.label}
          </button>
        ))}
      </div>
      {tabs.map((tab) => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          hidden={activeTab !== tab.id}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

// Accessible dropdown/combobox
<div>
  <label htmlFor="country">Country</label>
  <input
    id="country"
    role="combobox"
    aria-expanded={isOpen}
    aria-controls="country-list"
    aria-autocomplete="list"
    value={value}
    onChange={handleChange}
  />
  {isOpen && (
    <ul id="country-list" role="listbox">
      {options.map((option) => (
        <li
          key={option.id}
          role="option"
          aria-selected={value === option.label}
        >
          {option.label}
        </li>
      ))}
    </ul>
  )}
</div>

// Testing checklist for screen readers
// 1. Can you navigate with keyboard only?
// 2. Does Tab key move logically?
// 3. Are headings announced correctly?
// 4. Are landmarks (nav, main, footer) announced?
// 5. Are form labels read before inputs?
// 6. Are error messages announced?
// 7. Are button purposes clear?
// 8. Are icons described?
// 9. Can you complete main tasks?
// 10. Is dynamic content announced?

// React hook for screen reader announcements
function useScreenReaderAnnounce() {
  const [message, setMessage] = useState('');
  
  const announce = useCallback((msg, priority = 'polite') => {
    setMessage('');
    setTimeout(() => {
      setMessage(msg);
    }, 100);
  }, []);
  
  return {
    announce,
    LiveRegion: () => (
      <div
        role="status"
        aria-live="polite"
        aria-atomic="true"
        style={{
          position: 'absolute',
          left: '-10000px',
          width: '1px',
          height: '1px',
          overflow: 'hidden'
        }}
      >
        {message}
      </div>
    )
  };
}

Screen Reader Testing Checklist

Common Screen Reader Issues

  • ❌ Missing alt text on images
  • ❌ Unlabeled form inputs
  • ❌ Poor heading structure
  • ❌ Icon buttons without labels
  • ❌ Custom controls without ARIA
  • ❌ Dynamic content not announced
  • ❌ Focus lost after interactions
  • ❌ Decorative images not hidden
User Testing: Nothing beats real users. Hire screen reader users for usability testing. Automated tools catch only ~30% of issues.

3. Automated Testing (axe-core, Lighthouse, pa11y)

Tool Type Coverage Integration
axe-core BEST JavaScript library, browser extension ~57% of WCAG issues (best in class) Jest, Cypress, Playwright, Chrome DevTools
Lighthouse Chrome DevTools audit ~40% of issues (powered by axe-core) Chrome, CI/CD, PageSpeed Insights
WAVE Browser extension, API ~45% of issues, visual feedback Manual testing, browser extension
Pa11y CLI tool, CI/CD ~40% (uses HTML_CodeSniffer) Command line, automated testing

Example: Automated Accessibility Testing

// Install axe-core for Jest + React Testing Library
npm install --save-dev jest-axe

// jest.setup.js
import 'jest-axe/extend-expect';

// Component test with axe
// Button.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Button } from './Button';

expect.extend(toHaveNoViolations);

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

// Test with custom rules
test('custom axe rules', async () => {
  const { container } = render(<MyComponent />);
  const results = await axe(container, {
    rules: {
      'color-contrast': { enabled: true },
      'label': { enabled: true }
    }
  });
  expect(results).toHaveNoViolations();
});

// Cypress with axe
// cypress/support/commands.ts
import 'cypress-axe';

// cypress/e2e/accessibility.cy.ts
describe('Accessibility tests', () => {
  beforeEach(() => {
    cy.visit('/');
    cy.injectAxe();
  });
  
  it('Has no detectable accessibility violations', () => {
    cy.checkA11y();
  });
  
  it('Checks specific element', () => {
    cy.checkA11y('.main-content');
  });
  
  it('Excludes specific elements', () => {
    cy.checkA11y(null, {
      exclude: [['.third-party-widget']]
    });
  });
  
  it('Checks with specific rules', () => {
    cy.checkA11y(null, {
      rules: {
        'color-contrast': { enabled: true }
      }
    });
  });
});

// Playwright with axe
npm install --save-dev @axe-core/playwright

// tests/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('should not have any automatically detectable accessibility issues', async ({ page }) => {
  await page.goto('http://localhost:3000');
  
  const accessibilityScanResults = await new AxeBuilder({ page })
    .analyze();
  
  expect(accessibilityScanResults.violations).toEqual([]);
});

// With specific tags
test('WCAG 2.1 Level AA compliance', async ({ page }) => {
  await page.goto('http://localhost:3000');
  
  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
    .analyze();
  
  expect(results.violations).toEqual([]);
});

// Exclude third-party content
test('accessibility excluding ads', async ({ page }) => {
  await page.goto('http://localhost:3000');
  
  const results = await new AxeBuilder({ page })
    .exclude('.advertisement')
    .analyze();
  
  expect(results.violations).toEqual([]);
});

// Storybook with addon-a11y
// .storybook/main.ts
export default {
  addons: ['@storybook/addon-a11y']
};

// Button.stories.tsx - violations shown in panel
export default {
  title: 'Components/Button',
  component: Button,
  parameters: {
    a11y: {
      config: {
        rules: [
          {
            id: 'color-contrast',
            enabled: true
          }
        ]
      }
    }
  }
};

// Disable a11y for specific story
export const LowContrast: Story = {
  parameters: {
    a11y: {
      disable: true
    }
  }
};

// Chrome DevTools Lighthouse
// Run from DevTools > Lighthouse tab
// Or programmatically
npm install -g lighthouse

lighthouse https://example.com --only-categories=accessibility --output html --output-path ./report.html

// Pa11y CLI
npm install -g pa11y

// Test single page
pa11y https://example.com

// Test with specific standard
pa11y --standard WCAG2AA https://example.com

// JSON output
pa11y --reporter json https://example.com > results.json

// Pa11y CI for multiple pages
// .pa11yci.json
{
  "defaults": {
    "standard": "WCAG2AA",
    "timeout": 10000
  },
  "urls": [
    "http://localhost:3000/",
    "http://localhost:3000/about",
    "http://localhost:3000/contact"
  ]
}

// Run
pa11y-ci

// GitHub Actions for accessibility
// .github/workflows/a11y.yml
name: Accessibility Tests

on: [push, pull_request]

jobs:
  a11y:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm ci
      - run: npm run build
      - run: npm run serve &
      - run: sleep 5
      - run: npx pa11y-ci

// React hook for runtime a11y checks (dev only)
import { useEffect } from 'react';

function useA11yCheck() {
  useEffect(() => {
    if (process.env.NODE_ENV === 'development') {
      import('@axe-core/react').then((axe) => {
        axe.default(React, ReactDOM, 1000);
      });
    }
  }, []);
}

// Custom axe rules
const customRule = {
  id: 'custom-button-name',
  impact: 'serious',
  selector: 'button',
  any: [{
    id: 'has-accessible-name',
    evaluate: (node) => {
      return node.hasAttribute('aria-label') || node.textContent.trim();
    }
  }]
};

axe.configure({
  rules: [customRule]
});

// Accessibility testing strategy
// 1. Automated tests (30% of issues)
//    - Run axe-core in unit tests
//    - Run Lighthouse in CI/CD
//    - Use Storybook addon-a11y
// 2. Manual keyboard testing (40% of issues)
//    - Tab through entire page
//    - Test with screen reader
// 3. User testing (30% of issues)
//    - Test with real users with disabilities

Automated Testing Benefits

  • ✅ Fast feedback during development
  • ✅ Catches ~57% of WCAG issues (axe)
  • ✅ Prevents regressions
  • ✅ CI/CD integration
  • ✅ Component-level testing
  • ✅ Educates developers
  • ⚠️ Can't replace manual testing
  • ⚠️ Misses keyboard/SR issues

Axe-core Rule Categories

Category Example Rules
Color Contrast color-contrast
Forms label, input-button-name
Images image-alt, object-alt
Keyboard tabindex, focus-order
ARIA aria-valid-attr, aria-roles
Best Practice: Run axe-core in unit tests, E2E tests, Storybook, and CI/CD. Catch issues before production. Used by Microsoft, Google, Netflix.
Pattern Use Case Behavior Implementation
Roving Tabindex Toolbars, menus, grids One tab stop, arrow keys navigate Only active item has tabindex="0"
Focus Trap Modals, dialogs Keep focus within container Cycle focus between first/last
Focus Management SPA navigation, dynamic content Move focus after actions Focus heading after navigation
Skip Links Bypass repetitive content Jump to main content Hidden link, visible on focus

Example: Focus Management Patterns

// Roving tabindex for toolbar
function Toolbar({ items }) {
  const [focusedIndex, setFocusedIndex] = useState(0);
  const itemRefs = useRef([]);
  
  const handleKeyDown = (e, index) => {
    let newIndex = index;
    
    switch (e.key) {
      case 'ArrowRight':
        e.preventDefault();
        newIndex = (index + 1) % items.length;
        break;
      case 'ArrowLeft':
        e.preventDefault();
        newIndex = (index - 1 + items.length) % items.length;
        break;
      case 'Home':
        e.preventDefault();
        newIndex = 0;
        break;
      case 'End':
        e.preventDefault();
        newIndex = items.length - 1;
        break;
      default:
        return;
    }
    
    setFocusedIndex(newIndex);
    itemRefs.current[newIndex]?.focus();
  };
  
  return (
    <div role="toolbar" aria-label="Text formatting">
      {items.map((item, index) => (
        <button
          key={item.id}
          ref={(el) => (itemRefs.current[index] = el)}
          tabIndex={index === focusedIndex ? 0 : -1}
          onKeyDown={(e) => handleKeyDown(e, index)}
          onClick={item.onClick}
          aria-label={item.label}
        >
          {item.icon}
        </button>
      ))}
    </div>
  );
}

// Focus trap for modal
function useFocusTrap(ref) {
  useEffect(() => {
    if (!ref.current) return;
    
    const focusableElements = ref.current.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];
    
    const handleTab = (e) => {
      if (e.key !== 'Tab') return;
      
      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement.focus();
        }
      } else {
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement.focus();
        }
      }
    };
    
    ref.current.addEventListener('keydown', handleTab);
    firstElement?.focus();
    
    return () => {
      ref.current?.removeEventListener('keydown', handleTab);
    };
  }, [ref]);
}

// Modal with focus trap
function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef(null);
  const previousFocus = useRef(null);
  
  useFocusTrap(modalRef);
  
  useEffect(() => {
    if (isOpen) {
      previousFocus.current = document.activeElement;
    } else if (previousFocus.current) {
      previousFocus.current.focus();
    }
  }, [isOpen]);
  
  if (!isOpen) return null;
  
  return (
    <div className="modal-backdrop">
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
      >
        <h2 id="modal-title">Modal Title</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

// Focus management for SPA navigation
function useRouteAnnouncement() {
  const location = useLocation();
  const [message, setMessage] = useState('');
  
  useEffect(() => {
    // Focus main heading after navigation
    const mainHeading = document.querySelector('h1');
    if (mainHeading) {
      mainHeading.setAttribute('tabindex', '-1');
      mainHeading.focus();
      mainHeading.removeAttribute('tabindex');
    }
    
    // Announce page change
    const pageName = document.title;
    setMessage(`Navigated to ${pageName}`);
  }, [location]);
  
  return (
    <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
      {message}
    </div>
  );
}

// Skip navigation link
<a href="#main-content" className="skip-link">
  Skip to main content
</a>

<header>
  <nav>{/* Navigation */}</nav>
</header>

<main id="main-content" tabIndex="-1">
  {/* Main content */}
</main>

// CSS for skip link
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  padding: 8px;
  background: #000;
  color: #fff;
  text-decoration: none;
  z-index: 100;
}

.skip-link:focus {
  top: 0;
}

// Dropdown menu with roving tabindex
function DropdownMenu({ items }) {
  const [isOpen, setIsOpen] = useState(false);
  const [focusedIndex, setFocusedIndex] = useState(0);
  const itemRefs = useRef([]);
  
  const handleKeyDown = (e) => {
    if (!isOpen) {
      if (e.key === 'ArrowDown') {
        e.preventDefault();
        setIsOpen(true);
        setFocusedIndex(0);
      }
      return;
    }
    
    let newIndex = focusedIndex;
    
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        newIndex = (focusedIndex + 1) % items.length;
        break;
      case 'ArrowUp':
        e.preventDefault();
        newIndex = (focusedIndex - 1 + items.length) % items.length;
        break;
      case 'Home':
        e.preventDefault();
        newIndex = 0;
        break;
      case 'End':
        e.preventDefault();
        newIndex = items.length - 1;
        break;
      case 'Escape':
        e.preventDefault();
        setIsOpen(false);
        return;
      case 'Enter':
      case ' ':
        e.preventDefault();
        items[focusedIndex].onClick();
        setIsOpen(false);
        return;
      default:
        return;
    }
    
    setFocusedIndex(newIndex);
    itemRefs.current[newIndex]?.focus();
  };
  
  return (
    <div>
      <button
        aria-haspopup="true"
        aria-expanded={isOpen}
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={handleKeyDown}
      >
        Menu
      </button>
      {isOpen && (
        <ul role="menu">
          {items.map((item, index) => (
            <li
              key={item.id}
              role="menuitem"
              ref={(el) => (itemRefs.current[index] = el)}
              tabIndex={index === focusedIndex ? 0 : -1}
              onKeyDown={handleKeyDown}
              onClick={item.onClick}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

// Focus management after delete action
function deleteItem(id) {
  // Delete item
  api.deleteItem(id);
  
  // Focus next item or previous if last
  const nextItem = document.querySelector(`[data-id="${id}"]`)?.nextElementSibling;
  const prevItem = document.querySelector(`[data-id="${id}"]`)?.previousElementSibling;
  
  if (nextItem) {
    nextItem.focus();
  } else if (prevItem) {
    prevItem.focus();
  } else {
    // No items left, focus add button
    document.querySelector('.add-button')?.focus();
  }
}

// Grid navigation (2D roving tabindex)
function Grid({ rows, cols }) {
  const [focusedCell, setFocusedCell] = useState({ row: 0, col: 0 });
  const cellRefs = useRef({});
  
  const handleKeyDown = (e, row, col) => {
    let newRow = row;
    let newCol = col;
    
    switch (e.key) {
      case 'ArrowRight':
        newCol = Math.min(col + 1, cols - 1);
        break;
      case 'ArrowLeft':
        newCol = Math.max(col - 1, 0);
        break;
      case 'ArrowDown':
        newRow = Math.min(row + 1, rows - 1);
        break;
      case 'ArrowUp':
        newRow = Math.max(row - 1, 0);
        break;
      case 'Home':
        if (e.ctrlKey) {
          newRow = 0;
          newCol = 0;
        } else {
          newCol = 0;
        }
        break;
      case 'End':
        if (e.ctrlKey) {
          newRow = rows - 1;
          newCol = cols - 1;
        } else {
          newCol = cols - 1;
        }
        break;
      default:
        return;
    }
    
    e.preventDefault();
    setFocusedCell({ row: newRow, col: newCol });
    cellRefs.current[`${newRow}-${newCol}`]?.focus();
  };
  
  return (
    <div role="grid">
      {Array.from({ length: rows }).map((_, row) => (
        <div key={row} role="row">
          {Array.from({ length: cols }).map((_, col) => (
            <div
              key={col}
              role="gridcell"
              ref={(el) => (cellRefs.current[`${row}-${col}`] = el)}
              tabIndex={focusedCell.row === row && focusedCell.col === col ? 0 : -1}
              onKeyDown={(e) => handleKeyDown(e, row, col)}
            >
              Cell {row},{col}
            </div>
          ))}
        </div>
      ))}
    </div>
  );
}

// Focus visible styles
button:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

// Remove default outline, add custom for focus-visible
button:focus {
  outline: none;
}

button:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
  box-shadow: 0 0 0 4px rgba(0, 102, 204, 0.2);
}

Focus Management Patterns

Pattern Keys
Toolbar ←→ Home End
Menu ↑↓ Home End Enter Esc
Tabs ←→ Home End
Grid ←→↑↓ Home End Ctrl+Home/End
Tree ←→↑↓ Home End * Enter

Focus Management Checklist

ARIA Authoring Practices: Follow W3C ARIA patterns for standard widgets. Don't reinvent the wheel - use established patterns for menus, tabs, grids.

5. Color Contrast (WCAG AAA, Contrast Ratio, WebAIM)

Standard Ratio Use Case Example
WCAG AA (Normal) 4.5:1 minimum Body text, small text #767676 on white = 4.54:1 ✅
WCAG AA (Large) 3:1 minimum 18pt+ or 14pt+ bold #959595 on white = 3.02:1 ✅
WCAG AAA (Normal) 7:1 minimum Enhanced contrast #595959 on white = 7.01:1 ✅
UI Components 3:1 minimum Buttons, icons, borders #767676 border = 4.54:1 ✅

Example: Color Contrast Implementation

// Color contrast calculation
function getContrastRatio(color1, color2) {
  const l1 = getRelativeLuminance(color1);
  const l2 = getRelativeLuminance(color2);
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);
  return (lighter + 0.05) / (darker + 0.05);
}

function getRelativeLuminance(color) {
  const rgb = hexToRgb(color);
  const [r, g, b] = rgb.map((val) => {
    val = val / 255;
    return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

// Check if contrast meets WCAG AA
function meetsWCAGAA(foreground, background, isLarge = false) {
  const ratio = getContrastRatio(foreground, background);
  const threshold = isLarge ? 3 : 4.5;
  return ratio >= threshold;
}

// Example usage
console.log(meetsWCAGAA('#767676', '#FFFFFF')); // true (4.54:1)
console.log(meetsWCAGAA('#999999', '#FFFFFF')); // false (2.85:1)

// Accessible color palette
const colors = {
  // Text on white background
  textPrimary: '#212529',    // 16.1:1 ✅
  textSecondary: '#6c757d',  // 4.69:1 ✅
  textDisabled: '#adb5bd',   // 2.73:1 ❌ (decorative only)
  
  // Backgrounds for white text
  primaryBg: '#0066cc',      // 4.5:1 ✅
  successBg: '#28a745',      // 3.04:1 ✅ (large text)
  dangerBg: '#dc3545',       // 4.5:1 ✅
  
  // UI components
  border: '#dee2e6',         // 1.5:1 (not text, OK for border)
  iconActive: '#495057'      // 9.1:1 ✅
};

// CSS with good contrast
:root {
  --text-primary: #212529;
  --text-secondary: #6c757d;
  --bg-primary: #ffffff;
  --bg-secondary: #f8f9fa;
  --border: #dee2e6;
  --link: #0066cc;
  --link-hover: #004499;
}

body {
  color: var(--text-primary);
  background: var(--bg-primary);
}

a {
  color: var(--link);
}

a:hover {
  color: var(--link-hover);
}

// Dark mode with good contrast
[data-theme="dark"] {
  --text-primary: #f8f9fa;     // 16.1:1 on dark
  --text-secondary: #adb5bd;   // 6.8:1 on dark
  --bg-primary: #212529;
  --bg-secondary: #343a40;
  --border: #495057;
  --link: #66b3ff;             // 5.2:1 on dark
}

// Contrast-safe color function
function getAccessibleColor(background) {
  const lightText = '#ffffff';
  const darkText = '#212529';
  
  const lightRatio = getContrastRatio(lightText, background);
  const darkRatio = getContrastRatio(darkText, background);
  
  return lightRatio >= 4.5 ? lightText : darkText;
}

// Auto-adjust text color based on background
function Button({ backgroundColor, children }) {
  const textColor = getAccessibleColor(backgroundColor);
  
  return (
    <button style={{ backgroundColor, color: textColor }}>
      {children}
    </button>
  );
}

// Common contrast failures
// ❌ Gray text on white background
.text-muted {
  color: #999999; // 2.85:1 - FAILS
}

// ✅ Fix: Use darker gray
.text-muted {
  color: #6c757d; // 4.69:1 - PASSES
}

// ❌ Light blue link on white
a {
  color: #66b3ff; // 3.2:1 - FAILS
}

// ✅ Fix: Use darker blue
a {
  color: #0066cc; // 4.5:1 - PASSES
}

// ❌ Yellow warning on white
.warning {
  background: #ffc107; // 1.8:1 with white text - FAILS
  color: white;
}

// ✅ Fix: Add dark text or darker background
.warning {
  background: #ffc107;
  color: #212529; // 8.4:1 - PASSES
}

// Testing tools integration
// Jest test for contrast
import { getContrastRatio } from './colorUtils';

test('primary button has sufficient contrast', () => {
  const bgColor = '#0066cc';
  const textColor = '#ffffff';
  const ratio = getContrastRatio(bgColor, textColor);
  expect(ratio).toBeGreaterThanOrEqual(4.5);
});

// Storybook addon for contrast checking
// Shows contrast ratio in panel
export default {
  title: 'Components/Button',
  component: Button,
  parameters: {
    a11y: {
      config: {
        rules: [
          {
            id: 'color-contrast',
            enabled: true
          }
        ]
      }
    }
  }
};

// Tailwind with accessible colors
// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        // All colors meet WCAG AA with white text
        primary: {
          DEFAULT: '#0066cc', // 4.5:1
          dark: '#004499'     // 7.9:1
        },
        secondary: {
          DEFAULT: '#6c757d', // 4.69:1
          dark: '#495057'     // 9.1:1
        }
      }
    }
  }
};

// Design system with contrast tokens
const designTokens = {
  color: {
    text: {
      primary: { value: '#212529', contrast: '16.1:1' },
      secondary: { value: '#6c757d', contrast: '4.69:1' }
    },
    background: {
      primary: { value: '#ffffff' },
      secondary: { value: '#f8f9fa' }
    }
  }
};

// Browser DevTools contrast checker
// Chrome DevTools > Inspect element > Styles panel
// Shows contrast ratio with AA/AAA badges

// WebAIM Contrast Checker
// https://webaim.org/resources/contrastchecker/
// Enter foreground and background colors
// See ratio and pass/fail for WCAG AA/AAA

Contrast Quick Reference

Text Size WCAG AA WCAG AAA
<18pt normal 4.5:1 7:1
<14pt bold 4.5:1 7:1
≥18pt normal 3:1 4.5:1
≥14pt bold 3:1 4.5:1
UI components 3:1 N/A

Contrast Testing Tools

  • WebAIM: Online contrast checker
  • Chrome DevTools: Built-in inspector
  • Stark: Figma/Sketch plugin
  • Color Oracle: Colorblindness simulator
  • Lighthouse: Automated audit
  • axe DevTools: Browser extension
  • Contrast Ratio: lea.verou.me/contrast-ratio
Colorblindness: 8% of men, 0.5% of women have color vision deficiency. Don't rely on color alone - use icons, text, patterns.

6. ARIA (Live Regions, Roles, aria-label, Dynamic Content)

Attribute Behavior Interruption Use Case
aria-live="polite" Announce when idle No interruption Status updates, notifications
aria-live="assertive" Announce immediately Interrupts current speech Errors, warnings, urgent alerts
role="status" Polite live region No interruption Status messages (implicit aria-live="polite")
role="alert" Assertive live region Interrupts Important errors (implicit aria-live="assertive")

Example: ARIA Live Regions

// Basic live region for status updates
<div role="status" aria-live="polite" aria-atomic="true">
  {message}
</div>

// Alert for errors
<div role="alert" aria-live="assertive">
  {errorMessage}
</div>

// React hook for announcements
function useAnnouncer() {
  const [message, setMessage] = useState('');
  const [key, setKey] = useState(0);
  
  const announce = useCallback((text, priority = 'polite') => {
    setMessage('');
    setTimeout(() => {
      setMessage(text);
      setKey(k => k + 1);
    }, 100);
  }, []);
  
  return {
    announce,
    LiveRegion: ({ priority = 'polite' }) => (
      <div
        key={key}
        role={priority === 'assertive' ? 'alert' : 'status'}
        aria-live={priority}
        aria-atomic="true"
        className="sr-only"
      >
        {message}
      </div>
    )
  };
}

// Usage in component
function SearchResults() {
  const { announce, LiveRegion } = useAnnouncer();
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    if (results.length > 0) {
      announce(`${results.length} results found`);
    } else {
      announce('No results found');
    }
  }, [results, announce]);
  
  return (
    <>
      <LiveRegion />
      <ul>
        {results.map(result => (
          <li key={result.id}>{result.name}</li>
        ))}
      </ul>
    </>
  );
}

// Form validation with live region
function LoginForm() {
  const [errors, setErrors] = useState({});
  const { announce, LiveRegion } = useAnnouncer();
  
  const handleSubmit = (e) => {
    e.preventDefault();
    const validationErrors = validate(formData);
    
    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
      announce(
        `Form has ${Object.keys(validationErrors).length} errors`,
        'assertive'
      );
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <LiveRegion priority="assertive" />
      {/* Form fields */}
    </form>
  );
}

// Loading state announcement
function DataTable() {
  const [loading, setLoading] = useState(false);
  const { announce, LiveRegion } = useAnnouncer();
  
  useEffect(() => {
    if (loading) {
      announce('Loading data, please wait');
    } else {
      announce('Data loaded');
    }
  }, [loading, announce]);
  
  return (
    <>
      <LiveRegion />
      {loading ? <Spinner aria-hidden="true" /> : <Table />}
    </>
  );
}

// Timer countdown announcement
function Countdown({ seconds }) {
  const { announce, LiveRegion } = useAnnouncer();
  const [remaining, setRemaining] = useState(seconds);
  
  useEffect(() => {
    if (remaining === 60 || remaining === 30 || remaining === 10) {
      announce(`${remaining} seconds remaining`);
    } else if (remaining === 0) {
      announce('Time is up', 'assertive');
    }
  }, [remaining, announce]);
  
  return (
    <>
      <LiveRegion />
      <div>{remaining} seconds</div>
    </>
  );
}

// Shopping cart update
function Cart({ items }) {
  const { announce, LiveRegion } = useAnnouncer();
  const prevItemCount = useRef(items.length);
  
  useEffect(() => {
    const currentCount = items.length;
    const prevCount = prevItemCount.current;
    
    if (currentCount > prevCount) {
      announce(`Item added to cart. ${currentCount} items in cart`);
    } else if (currentCount < prevCount) {
      announce(`Item removed from cart. ${currentCount} items in cart`);
    }
    
    prevItemCount.current = currentCount;
  }, [items, announce]);
  
  return (
    <>
      <LiveRegion />
      <ul>
        {items.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </>
  );
}

// aria-atomic: Announce entire region or just changes
<div role="status" aria-live="polite" aria-atomic="true">
  {/* Entire content announced */}
  Score: {score}
</div>

<div role="status" aria-live="polite" aria-atomic="false">
  {/* Only changes announced */}
  Score: {score}
</div>

// aria-relevant: What changes to announce
<div
  role="log"
  aria-live="polite"
  aria-relevant="additions" {/* Only new items announced */}
>
  {messages.map(msg => (
    <div key={msg.id}>{msg.text}</div>
  ))}
</div>

// Possible values:
// - additions: New nodes
// - removals: Removed nodes
// - text: Text changes
// - all: All changes (default)

// Chat messages with live region
function Chat({ messages }) {
  return (
    <div
      role="log"
      aria-live="polite"
      aria-relevant="additions"
      aria-label="Chat messages"
    >
      {messages.map(msg => (
        <div key={msg.id}>
          <strong>{msg.author}:</strong> {msg.text}
        </div>
      ))}
    </div>
  );
}

// Progress bar with announcements
function ProgressBar({ value, max = 100 }) {
  const { announce, LiveRegion } = useAnnouncer();
  const prevValue = useRef(value);
  
  useEffect(() => {
    const percentage = Math.round((value / max) * 100);
    const prevPercentage = Math.round((prevValue.current / max) * 100);
    
    // Announce every 10%
    if (percentage % 10 === 0 && percentage !== prevPercentage) {
      announce(`${percentage}% complete`);
    }
    
    if (percentage === 100) {
      announce('Upload complete', 'assertive');
    }
    
    prevValue.current = value;
  }, [value, max, announce]);
  
  return (
    <>
      <LiveRegion />
      <div
        role="progressbar"
        aria-valuenow={value}
        aria-valuemin={0}
        aria-valuemax={max}
        aria-label="Upload progress"
      >
        <div style={{ width: `${(value / max) * 100}%` }} />
      </div>
    </>
  );
}

// Combobox with results announcement
function Autocomplete() {
  const [results, setResults] = useState([]);
  const { announce, LiveRegion } = useAnnouncer();
  
  useEffect(() => {
    if (results.length > 0) {
      announce(`${results.length} suggestions available`);
    }
  }, [results, announce]);
  
  return (
    <>
      <LiveRegion />
      <input
        role="combobox"
        aria-autocomplete="list"
        aria-controls="suggestions"
      />
      <ul id="suggestions" role="listbox">
        {results.map(result => (
          <li key={result.id} role="option">
            {result.label}
          </li>
        ))}
      </ul>
    </>
  );
}

Live Region Best Practices

  • ✅ Use role="status" for status updates
  • ✅ Use role="alert" for errors
  • ✅ Keep messages concise
  • ✅ Use aria-atomic="true" for full message
  • ✅ Debounce rapid updates
  • ✅ Don't overuse assertive
  • ⚠️ Live region must exist in DOM before updates
  • ⚠️ Empty and refill to re-announce same message

Common Use Cases

  • Form errors: role="alert"
  • Loading states: role="status"
  • Search results: role="status"
  • Cart updates: role="status"
  • Chat messages: role="log"
  • Timer alerts: role="timer"
  • Progress updates: progressbar + status

Accessibility Implementation Summary

  • WCAG 2.1 AA: Legal requirement in US/EU. 50 criteria covering images, contrast, keyboard, forms, focus, labels
  • Screen Readers: Test with NVDA (Windows), VoiceOver (Mac/iOS). 70%+ users use these. Use semantic HTML and ARIA
  • Automated Testing: axe-core catches ~57% of issues. Integrate with Jest, Cypress, Storybook, CI/CD
  • Focus Management: Roving tabindex for toolbars/menus, focus trap in modals, restore focus after actions
  • Color Contrast: 4.5:1 for normal text, 3:1 for large text and UI. Use WebAIM checker, Chrome DevTools
  • Live Regions: aria-live="polite" for status, "assertive" for errors. Announce dynamic content for screen readers
Legal Risk: Over 10,000 ADA lawsuits filed in 2023. Fines up to $75,000. Make accessibility a priority from day one, not an afterthought.