Accessibility Implementation Best Practices

1. WCAG 2.1 AA Compliance Implementation

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

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. Axe-core Automated Accessibility Testing

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.

4. Focus Management Roving Tabindex

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 Checker 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 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.