WCAG 2.2 Success Criteria: A Developer's Implementation Guide

Practical implementation guide for WCAG 2.2 success criteria. Learn how to meet new requirements including Focus Not Obscured, Dragging Movements, and more.

WCAG 2.2 Success Criteria: A Developer's Implementation Guide

Implementing WCAG 2.2 success criteria requires a blend of technical expertise, user empathy, and practical problem-solving. This comprehensive guide provides actionable, production-ready solutions for developers committed to building accessible web experiences.

Table of Contents

  1. Focus Management
  2. Dragging Movements
  3. Target Size Requirements
  4. Accessible Authentication
  5. Testing Strategies
  6. Common Pitfalls

Focus Management

Focus Management

Understanding 2.4.11 Focus Not Obscured (Minimum)

The most impactful new criterion in WCAG 2.2, 2.4.11 ensures keyboard users can always see where their focus is located.

The Problem

Consider this common scenario: A user tabs through a navigation menu. When they reach a link near the bottom, the focus indicator appears, but it's hidden behind a sticky header that follows them as they scroll. They can't see where they are, creating confusion and frustration.

Production-Ready Solution

/* Base focus styles - always visible */
*:focus-visible {
  outline: 3px solid #0066cc;
  outline-offset: 2px;
  z-index: 9999;
  position: relative;
}

/* Ensure focus appears above common overlays */
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
  z-index: 10000;
}

/* Sticky headers and navigation */
.sticky-header,
.sticky-nav {
  z-index: 100;
  position: sticky;
  top: 0;
}

/* Modals and overlays */
.modal,
.overlay {
  z-index: 1000;
}

/* Focused content inside modals */
.modal *:focus-visible {
  z-index: 1001;
}

JavaScript Enhancement

/**
 * Scroll focused element into view with proper offset
 * Handles sticky headers and ensures visibility
 */
function ensureFocusVisible(element) {
  if (!element) return;
  
  const rect = element.getBoundingClientRect();
  const headerHeight = document.querySelector('.sticky-header')?.offsetHeight || 0;
  const viewportHeight = window.innerHeight;
  
  // Check if element is obscured
  const isObscured = rect.top < headerHeight || rect.bottom > viewportHeight;
  
  if (isObscured) {
    element.scrollIntoView({
      behavior: 'smooth',
      block: 'center',
      inline: 'nearest'
    });
    
    // Additional offset for sticky headers
    if (headerHeight > 0) {
      window.scrollBy(0, -headerHeight - 10);
    }
  }
}

// Apply to all focusable elements
document.addEventListener('focusin', (e) => {
  ensureFocusVisible(e.target);
});

// Handle programmatic focus
function focusElement(selector) {
  const element = document.querySelector(selector);
  if (element) {
    element.focus();
    ensureFocusVisible(element);
  }
}

React Implementation

import { useEffect, useRef } from 'react';

/**
 * Custom hook for accessible focus management
 */
function useAccessibleFocus() {
  const elementRef = useRef(null);
  
  useEffect(() => {
    const handleFocus = () => {
      if (elementRef.current) {
        elementRef.current.scrollIntoView({
          behavior: 'smooth',
          block: 'center'
        });
      }
    };
    
    const element = elementRef.current;
    if (element) {
      element.addEventListener('focus', handleFocus);
      return () => element.removeEventListener('focus', handleFocus);
    }
  }, []);
  
  return elementRef;
}

// Usage
function AccessibleButton({ children, ...props }) {
  const ref = useAccessibleFocus();
  
  return (
    <button ref={ref} {...props}>
      {children}
    </button>
  );
}

Dragging Movements

Drag and Drop Accessibility

Understanding 2.5.7 Dragging Movements

This criterion requires that all drag-and-drop functionality has keyboard and single-pointer alternatives.

Complete Implementation Example

<!-- Sortable List with Multiple Interaction Methods -->
<div class="sortable-list" role="list" aria-label="Reorderable items">
  <div class="sortable-item" role="listitem" tabindex="0">
    <span class="item-content">Item 1</span>
    <div class="item-controls">
      <button 
        class="move-up" 
        aria-label="Move item 1 up"
        onclick="moveItem(this, 'up')"
      >
        <svg aria-hidden="true"><!-- Up arrow icon --></svg>
      </button>
      <button 
        class="move-down" 
        aria-label="Move item 1 down"
        onclick="moveItem(this, 'down')"
      >
        <svg aria-hidden="true"><!-- Down arrow icon --></svg>
      </button>
      <span class="drag-handle" aria-label="Drag to reorder" draggable="true">
        <svg aria-hidden="true"><!-- Drag icon --></svg>
      </span>
    </div>
  </div>
  <!-- More items... -->
</div>
/**
 * Comprehensive sortable list with keyboard, button, and drag support
 */
class AccessibleSortableList {
  constructor(container) {
    this.container = container;
    this.items = Array.from(container.querySelectorAll('.sortable-item'));
    this.setupEventListeners();
  }
  
  setupEventListeners() {
    // Keyboard navigation (Arrow keys)
    this.items.forEach((item, index) => {
      item.setAttribute('data-index', index);
      
      item.addEventListener('keydown', (e) => {
        if (e.key === 'ArrowUp' && index > 0) {
          e.preventDefault();
          this.moveItem(index, index - 1);
        } else if (e.key === 'ArrowDown' && index < this.items.length - 1) {
          e.preventDefault();
          this.moveItem(index, index + 1);
        }
      });
      
      // Button controls
      const moveUp = item.querySelector('.move-up');
      const moveDown = item.querySelector('.move-down');
      
      if (moveUp) {
        moveUp.addEventListener('click', () => {
          if (index > 0) this.moveItem(index, index - 1);
        });
      }
      
      if (moveDown) {
        moveDown.addEventListener('click', () => {
          if (index < this.items.length - 1) this.moveItem(index, index + 1);
        });
      }
      
      // Drag and drop (optional enhancement)
      const dragHandle = item.querySelector('.drag-handle');
      if (dragHandle) {
        this.setupDragDrop(item, dragHandle);
      }
    });
  }
  
  moveItem(fromIndex, toIndex) {
    const item = this.items[fromIndex];
    const targetItem = this.items[toIndex];
    
    if (toIndex > fromIndex) {
      targetItem.after(item);
    } else {
      targetItem.before(item);
    }
    
    // Update indices and focus
    this.items = Array.from(this.container.querySelectorAll('.sortable-item'));
    this.items.forEach((item, index) => {
      item.setAttribute('data-index', index);
    });
    
    // Maintain focus
    item.focus();
    
    // Announce change to screen readers
    this.announceChange(fromIndex, toIndex);
  }
  
  setupDragDrop(item, handle) {
    let draggedElement = null;
    
    handle.addEventListener('dragstart', (e) => {
      draggedElement = item;
      e.dataTransfer.effectAllowed = 'move';
      item.classList.add('dragging');
    });
    
    handle.addEventListener('dragend', () => {
      item.classList.remove('dragging');
      draggedElement = null;
    });
    
    item.addEventListener('dragover', (e) => {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'move';
    });
    
    item.addEventListener('drop', (e) => {
      e.preventDefault();
      if (draggedElement && draggedElement !== item) {
        const fromIndex = parseInt(draggedElement.getAttribute('data-index'));
        const toIndex = parseInt(item.getAttribute('data-index'));
        this.moveItem(fromIndex, toIndex);
      }
    });
  }
  
  announceChange(fromIndex, toIndex) {
    const announcement = document.createElement('div');
    announcement.setAttribute('role', 'status');
    announcement.setAttribute('aria-live', 'polite');
    announcement.className = 'sr-only';
    announcement.textContent = `Item moved from position ${fromIndex + 1} to position ${toIndex + 1}`;
    document.body.appendChild(announcement);
    setTimeout(() => announcement.remove(), 1000);
  }
}

// Initialize
document.querySelectorAll('.sortable-list').forEach(list => {
  new AccessibleSortableList(list);
});

Target Size Requirements

Target Size

Understanding 2.5.8 Target Size (Minimum)

Touch targets must be at least 24×24 CSS pixels, with exceptions for inline text and user-agent controls.

CSS Solutions

/* Method 1: Direct sizing */
button,
a.button,
input[type="submit"],
input[type="button"] {
  min-width: 24px;
  min-height: 24px;
  padding: 8px 16px; /* Increases clickable area */
}

/* Method 2: Padding for small icons */
.icon-button {
  width: 16px;
  height: 16px;
  padding: 4px; /* Creates 24×24px total target */
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

/* Method 3: Pseudo-element expansion */
.small-link {
  position: relative;
  display: inline-block;
}

.small-link::before {
  content: '';
  position: absolute;
  top: -4px;
  left: -4px;
  right: -4px;
  bottom: -4px;
  /* Creates larger touch target */
}

/* Method 4: Flexbox spacing */
.button-group {
  display: flex;
  gap: 8px; /* Ensures adequate spacing between targets */
}

.button-group button {
  min-width: 24px;
  min-height: 24px;
}

HTML Best Practices

<!-- Good: Adequate target size -->
<button class="icon-button" aria-label="Close dialog">
  <svg width="16" height="16" aria-hidden="true">
    <use href="#close-icon"></use>
  </svg>
</button>

<!-- Good: Text link with adequate padding -->
<a href="/page" class="nav-link">Navigation Item</a>

<!-- Avoid: Tiny clickable area -->
<a href="/page" style="font-size: 10px; padding: 0;">Tiny Link</a>

Accessible Authentication

Authentication

Understanding 2.5.3 Accessible Authentication

Reduce cognitive load in authentication by supporting password managers and providing alternatives to cognitive function tests.

Implementation

<!-- Accessible Login Form -->
<form class="login-form" method="post" action="/api/auth/login">
  <div class="form-group">
    <label for="email">Email Address</label>
    <input
      type="email"
      id="email"
      name="email"
      autocomplete="email"
      required
      aria-describedby="email-help"
    />
    <small id="email-help" class="help-text">
      We'll never share your email with anyone else.
    </small>
  </div>
  
  <div class="form-group">
    <label for="password">Password</label>
    <input
      type="password"
      id="password"
      name="password"
      autocomplete="current-password"
      required
      aria-describedby="password-help"
    />
    <small id="password-help" class="help-text">
      Use a password manager for secure, easy access.
    </small>
  </div>
  
  <div class="form-actions">
    <button type="submit" class="btn-primary">
      Sign In
    </button>
    <a href="/auth/forgot-password" class="link-secondary">
      Forgot password?
    </a>
  </div>
  
  <!-- Alternative authentication methods -->
  <div class="auth-alternatives">
    <p>Or sign in with:</p>
    <button type="button" class="btn-social" aria-label="Sign in with Google">
      <svg><!-- Google icon --></svg>
      Google
    </button>
    <button type="button" class="btn-social" aria-label="Sign in with email link">
      <svg><!-- Email icon --></svg>
      Email Link
    </button>
  </div>
</form>

Testing Strategies

Testing

Automated Testing Suite

// Using axe-core for automated testing
import axe from 'axe-core';

async function runAccessibilityTests() {
  const results = await axe.run(document, {
    tags: ['wcag2a', 'wcag2aa', 'wcag22aa'],
    rules: {
      'focus-order-semantics': { enabled: true },
      'focusable-content': { enabled: true },
      'keyboard': { enabled: true }
    }
  });
  
  if (results.violations.length > 0) {
    console.error('Accessibility violations found:', results.violations);
    return false;
  }
  
  return true;
}

// Run on page load and after dynamic updates
window.addEventListener('load', runAccessibilityTests);

Manual Testing Checklist

## Keyboard Navigation
- [ ] Tab through all interactive elements
- [ ] Focus indicators are visible
- [ ] Focus order is logical
- [ ] All functionality accessible via keyboard
- [ ] No keyboard traps

## Screen Reader Testing
- [ ] Test with NVDA (Windows)
- [ ] Test with JAWS (Windows)
- [ ] Test with VoiceOver (macOS/iOS)
- [ ] All content is announced correctly
- [ ] Form labels are properly associated
- [ ] Images have descriptive alt text

## Visual Testing
- [ ] High contrast mode works
- [ ] Zoom to 200% is usable
- [ ] Color is not the only indicator
- [ ] Text is readable at all sizes

## Mobile Testing
- [ ] Touch targets are at least 24×24px
- [ ] All functionality works on mobile
- [ ] No horizontal scrolling required
- [ ] Gestures have alternatives

Common Pitfalls and Solutions

Pitfall 1: Removing Focus Outlines

❌ Wrong:

*:focus {
  outline: none;
}

✅ Correct:

*:focus-visible {
  outline: 3px solid #0066cc;
  outline-offset: 2px;
}

Pitfall 2: Relying Only on Color

❌ Wrong:

<button style="color: red;">Error: Invalid input</button>

✅ Correct:

<button class="error-button" aria-label="Error: Invalid input">
  <svg aria-hidden="true"><!-- Error icon --></svg>
  Error: Invalid input
</button>

Pitfall 3: Missing Alternative Text

❌ Wrong:

<img src="chart.png" alt="" />

✅ Correct:

<img src="chart.png" alt="Sales increased 25% from Q1 to Q2 2024" />

Conclusion

Implementing WCAG 2.2 success criteria is an investment in inclusive design that benefits all users. By following these production-ready patterns and testing thoroughly, you can create web experiences that are not only compliant but truly accessible.

Remember: Accessibility is not a feature—it's a fundamental requirement.

For more resources, visit the WCAG 2.2 Quick Reference and WebAIM Guidelines.