Web Development20 min read2025-06-12

Web AccessibilityWCAG standards

Accessible web development according to WCAG standards for all users – Comprehensive guide for inclusive web experiences and legally compliant websites.

AccessibilityWCAGUXInclusive DesignA11y

What is Web Accessibility?

Web accessibility (often abbreviated as A11y) means that websites and web applications are usable by everyone, including people with disabilities. It is about designing digital content so that it can be perceived, understood, navigated, and operated by all.

  • 15% of the world’s population lives with some form of disability
  • Legal obligation in many countries (EU, USA, Germany)
  • Better UX for all: Accessibility improves the user experience for everyone
  • SEO benefits: Accessible websites often rank better

📏 WCAG 2.1 Standards

The Web Content Accessibility Guidelines (WCAG) 2.1 define three levels of conformance:

Level A (Minimum)

Basic accessibility features

Level AA (Standard)

Recommended level for most websites

Level AAA (Enhanced)

Highest level for specialized applications

The four WCAG principles:

1. Perceivable

Information must be perceptible to users

2. Operable

UI components must be operable

3. Understandable

Information and UI must be understandable

4. Robust

Content must be robust enough for various assistive technologies

👁️ Perceivable

Alternative texts for images:

<!-- Informative image -->
<img src="chart.png" alt="Sales increased by 25% last quarter" />

<!-- Decorative image -->
<img src="decoration.png" alt="" role="presentation" />

<!-- Complex graphics -->
<img src="complex-chart.png" alt="See detailed description below" />
<div id="chart-description">
  <h3>Detailed chart description</h3>
  <p>The bar chart shows...</p>
</div>

<!-- SVG Icons -->
<svg role="img" aria-labelledby="icon-title">
  <title id="icon-title">Open user profile</title>
  <path d="..."/>
</svg>

<!-- Background images with important content -->
<div class="hero-banner" role="img" aria-label="Team works together in a modern office">
  <h1>Our Vision</h1>
</div>

Color contrast and visual design:

/* WCAG AA contrast requirements */
/* Normal text: 4.5:1 */
/* Large text (18pt+ or 14pt+ bold): 3:1 */

:root {
  /* Good contrasts */
  --text-primary: #000000;     /* 21:1 on white background */
  --text-secondary: #424242;   /* 12.6:1 on white background */
  --link-color: #0066cc;       /* 7.7:1 on white background */
  
  /* Error colors with sufficient contrast */
  --error-color: #d32f2f;      /* 5.4:1 on white background */
  --success-color: #2e7d32;    /* 4.7:1 on white background */
}

/* Never use color alone to convey information */
.error-message {
  color: var(--error-color);
  position: relative;
}

.error-message::before {
  content: "⚠️ ";
  margin-right: 0.5rem;
}

/* Focus states must be visible */
button:focus,
a:focus,
input:focus {
  outline: 2px solid #005fcc;
  outline-offset: 2px;
}

/* Custom focus for better visibility */
.custom-focus:focus {
  box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.5);
  outline: none;
}

/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

Responsive text and zoom:

/* Text must remain readable up to 200% zoom */
body {
  font-size: 16px;
  line-height: 1.5;
}

/* Use relative units */
.content {
  font-size: 1rem;
  padding: 1em;
  margin: 1em 0;
}

/* Minimum touch target size: 44x44px */
button,
a,
input[type="checkbox"],
input[type="radio"] {
  min-height: 44px;
  min-width: 44px;
}

/* Text spacing for better readability */
p {
  margin-bottom: 1em;
  word-spacing: 0.16em;
  letter-spacing: 0.12em;
}

⌨️ Bedienbar (Operable)

Keyboard Navigation:

<!-- All interactive elements must be reachable by keyboard -->
<nav aria-label="Hauptnavigation">
  <ul>
    <li><a href="/home" tabindex="0">Home</a></li>
    <li><a href="/about" tabindex="0">About us</a></li>
    <li>
      <button 
        aria-expanded="false" 
        aria-haspopup="true"
        aria-controls="dropdown-menu"
        tabindex="0"
      >
        Services
      </button>
      <ul id="dropdown-menu" aria-hidden="true">
        <li><a href="/service1" tabindex="-1">Service 1</a></li>
        <li><a href="/service2" tabindex="-1">Service 2</a></li>
      </ul>
    </li>
  </ul>
</nav>

<!-- Skip links for better navigation -->
<a href="#main-content" class="skip-link">Skip to main content</a>

<!-- Modal with keyboard trap -->
<div role="dialog" aria-modal="true" aria-labelledby="modal-title">
  <h2 id="modal-title">Modal Title</h2>
  <button aria-label="Close modal" onclick="closeModal()">×</button>
  <div class="modal-content">
    <!-- Modal Inhalt -->
  </div>
</div>

JavaScript for accessibility:

// Focus management
function manageFocus() {
  const modal = document.getElementById('modal');
  const focusableElements = modal.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  
  const firstFocusable = focusableElements[0];
  const lastFocusable = focusableElements[focusableElements.length - 1];
  
  // Focus auf erstes Element setzen
  firstFocusable.focus();
  
  // Keyboard Trap implementieren
  modal.addEventListener('keydown', (e) => {
    if (e.key === 'Tab') {
      if (e.shiftKey) {
        if (document.activeElement === firstFocusable) {
          lastFocusable.focus();
          e.preventDefault();
        }
      } else {
        if (document.activeElement === lastFocusable) {
          firstFocusable.focus();
          e.preventDefault();
        }
      }
    }
    
    if (e.key === 'Escape') {
      closeModal();
    }
  });
}

// ARIA Live Regions für dynamische Inhalte
function announceMessage(message, priority = 'polite') {
  const announcer = document.getElementById('live-announcer');
  announcer.setAttribute('aria-live', priority);
  announcer.textContent = message;
  
  // Nach kurzer Zeit leeren
  setTimeout(() => {
    announcer.textContent = '';
  }, 1000);
}

// Form Validation mit Screen Reader Support
function validateForm(form) {
  const errors = [];
  const inputs = form.querySelectorAll('input[required]');
  
  inputs.forEach(input => {
    if (!input.value.trim()) {
      const label = form.querySelector(`label[for="${input.id}"]`);
      const fieldName = label ? label.textContent : input.name;
      errors.push(`${fieldName} ist erforderlich`);
      
      input.setAttribute('aria-invalid', 'true');
      input.setAttribute('aria-describedby', `${input.id}-error`);
    } else {
      input.removeAttribute('aria-invalid');
      input.removeAttribute('aria-describedby');
    }
  });
  
  if (errors.length > 0) {
    announceMessage(`${errors.length} Fehler gefunden: ${errors.join(', ')}`, 'assertive');
    return false;
  }
  
  return true;
}

CSS for better operability:

/* Skip link styling */
.skip-link {
  position: absolute;
  top: -40px;
  left: 6px;
  background: #000;
  color: #fff;
  padding: 8px;
  text-decoration: none;
  z-index: 1000;
}

.skip-link:focus {
  top: 6px;
}

/* Focus indicators must not be removed */
button:focus-visible,
a:focus-visible,
input:focus-visible {
  outline: 2px solid #005fcc;
  outline-offset: 2px;
}

/* Hover and focus states together */
button:hover,
button:focus {
  background-color: #0056b3;
  color: white;
}

/* Reduced Motion berücksichtigen */
@media (prefers-reduced-motion: reduce) {
  .animated-element {
    animation: none;
  }
}

🧠 Verständlich (Understandable)

Semantic HTML:

<!-- Correct heading hierarchy -->
<h1>Main page title</h1>
  <h2>Main section</h2>
    <h3>Subsection</h3>
    <h3>Another subsection</h3>
  <h2>Another main section</h2>

<!-- Landmarks for better navigation -->
<header role="banner">
  <nav aria-label="Main navigation">
    <!-- Navigation -->
  </nav>
</header>

<main role="main">
  <article>
    <h1>Article title</h1>
    <!-- Article content -->
  </article>
  
  <aside role="complementary" aria-label="Related links">
    <!-- Sidebar content -->
  </aside>
</main>

<footer role="contentinfo">
  <!-- Footer content -->
</footer>

<!-- Lists for related content -->
<ul>
  <li>List item 1</li>
  <li>List item 2</li>
</ul>

<!-- Tables with correct headers -->
<table>
  <caption>Sales by quarter</caption>
  <thead>
    <tr>
      <th scope="col">Quarter</th>
      <th scope="col">Sales</th>
      <th scope="col">Profit</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Q1 2024</th>
      <td>€50,000</td>
      <td>€10,000</td>
    </tr>
  </tbody>
</table>

Forms and labels:

<!-- Correct label association -->
<form>
  <div class="form-group">
    <label for="email">Email address *</label>
    <input 
      type="email" 
      id="email" 
      name="email" 
      required
      aria-describedby="email-help email-error"
      aria-invalid="false"
    />
    <div id="email-help" class="help-text">
      We only use your email for important updates
    </div>
    <div id="email-error" class="error-text" aria-live="polite">
      <!-- Error messages will be inserted here -->
    </div>
  </div>

  <!-- Grouped form fields -->
  <fieldset>
    <legend>Preferred contact method</legend>
    <div>
      <input type="radio" id="contact-email" name="contact" value="email" />
      <label for="contact-email">Email</label>
    </div>
    <div>
      <input type="radio" id="contact-phone" name="contact" value="phone" />
      <label for="contact-phone">Phone</label>
    </div>
  </fieldset>

  <!-- Submit button with descriptive text -->
  <button type="submit" aria-describedby="submit-help">
    Submit registration
  </button>
  <div id="submit-help" class="help-text">
    After submitting, you will receive a confirmation email
  </div>
</form>

ARIA labels and descriptions:

<!-- ARIA labels for extra context -->
<button aria-label="Open profile menu">
  <img src="avatar.jpg" alt="User avatar" />
</button>

<!-- ARIA describedby for extra information -->
<input 
  type="password" 
  aria-label="New password"
  aria-describedby="password-requirements"
/>
<div id="password-requirements">
  Password must be at least 8 characters long and contain numbers
</div>

<!-- Live regions for dynamic content -->
<div aria-live="polite" aria-atomic="true" id="status-message">
  <!-- Status updates will be inserted here -->
</div>

<!-- Progress indicators -->
<div role="progressbar" aria-valuenow="32" aria-valuemin="0" aria-valuemax="100" aria-label="Upload progress">
  <div class="progress-bar" style="width: 32%"></div>
</div>

<!-- Expandable content -->
<button 
  aria-expanded="false" 
  aria-controls="collapsible-content"
  aria-label="Show additional information"
>
  More details
</button>
<div id="collapsible-content" aria-hidden="true">
  <!-- Expandable content -->
</div>

🔧 Robust

Valid HTML and ARIA:

<!-- Correct HTML5 document -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Page title - Company name</title>
</head>
<body>
  <!-- Valid HTML structure -->
  <main>
    <!-- ARIA roles only when necessary -->
    <section aria-labelledby="section-title">
      <h2 id="section-title">Section title</h2>
      <!-- Content -->
    </section>
  </main>
</body>
</html>

<!-- Custom components with correct ARIA attributes -->
<div 
  role="button" 
  tabindex="0"
  aria-pressed="false"
  onkeydown="handleKeyDown(event)"
  onclick="toggleButton()"
>
  Toggle Button
</div>

<!-- Screen reader only content -->
<span class="sr-only">
  Additional context for screen readers only
</span>

JavaScript for robustness:

// Feature detection instead of browser detection
if ('IntersectionObserver' in window) {
  // Moderne Intersection Observer API verwenden
} else {
  // Fallback für ältere Browser
}

// Graceful Degradation
function enhanceWithJS() {
  if (!document.querySelector || !document.addEventListener) {
    return; // Basis-HTML funktioniert ohne JS
  }
  
  // Progressive Enhancement
  const buttons = document.querySelectorAll('[data-toggle]');
  buttons.forEach(button => {
    button.addEventListener('click', handleToggle);
    button.addEventListener('keydown', handleKeyDown);
  });
}

// Error Handling für bessere Robustheit
try {
  enhanceWithJS();
} catch (error) {
  console.error('Enhancement failed:', error);
  // Fallback auf Basis-Funktionalität
}

// Screen Reader Announcements
function announceToScreenReader(message) {
  const announcement = document.createElement('div');
  announcement.setAttribute('aria-live', 'assertive');
  announcement.setAttribute('aria-atomic', 'true');
  announcement.className = 'sr-only';
  announcement.textContent = message;
  
  document.body.appendChild(announcement);
  
  setTimeout(() => {
    document.body.removeChild(announcement);
  }, 1000);
}

🔍 Testing & Tools

Automated Testing Tools:

axe DevTools

Browser extension for automated A11y tests

Lighthouse

Google's Accessibility Audit in Chrome DevTools

WAVE

Web Accessibility Evaluation Tool

Pa11y

Command-line tool for automated tests

Testing with Jest and Testing Library:

// Accessibility Tests mit @testing-library/jest-dom
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

describe('Button Component Accessibility', () => {
  test('should not have accessibility violations', async () => {
    const { container } = render(
      <Button aria-label="Save document">Save</Button>
    );
    
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  test('should have correct ARIA attributes', () => {
    render(
      <Button aria-pressed="false" role="button">
        Toggle
      </Button>
    );
    
    const button = screen.getByRole('button');
    expect(button).toHaveAttribute('aria-pressed', 'false');
  });

  test('should be accessible via keyboard', () => {
    render(<Button>Click me</Button>);
    
    const button = screen.getByRole('button');
    expect(button).toBeInTheDocument();
    expect(button).not.toHaveAttribute('tabindex', '-1');
  });
});

// Screen Reader Testing Simulation
test('should announce changes to screen readers', async () => {
  render(<NotificationComponent />);
  
  const liveRegion = screen.getByRole('status');
  expect(liveRegion).toHaveAttribute('aria-live', 'polite');
});

Manual Testing Checklist:

  • ✅ Navigation only possible with keyboard
  • ✅ All content visible at 200% zoom
  • ✅ Screen reader test (NVDA, JAWS, VoiceOver)
  • ✅ Contrast ratios checked
  • ✅ Forms fully labeled
  • ✅ Error handling accessible
  • ✅ Videos have subtitles
  • ✅ No epilepsy-inducing content

⚙️ Practical Implementation

React Accessibility Hook:

// useAccessibility.ts
import { useEffect, useRef } from 'react';

export function useAccessibility() {
  const announceMessage = (message: string, priority: 'polite' | 'assertive' = 'polite') => {
    const announcement = document.createElement('div');
    announcement.setAttribute('aria-live', priority);
    announcement.setAttribute('aria-atomic', 'true');
    announcement.className = 'sr-only';
    announcement.textContent = message;
    
    document.body.appendChild(announcement);
    
    setTimeout(() => {
      if (document.body.contains(announcement)) {
        document.body.removeChild(announcement);
      }
    }, 1000);
  };

  const manageFocus = (elementId: string) => {
    const element = document.getElementById(elementId);
    if (element) {
      element.focus();
      element.scrollIntoView({ behavior: 'smooth', block: 'center' });
    }
  };

  return { announceMessage, manageFocus };
}

// Accessible Modal Component
interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

export function AccessibleModal({ isOpen, onClose, title, children }: ModalProps) {
  const modalRef = useRef<HTMLDivElement>(null);
  const { manageFocus } = useAccessibility();

  useEffect(() => {
    if (isOpen) {
      // Focus auf Modal setzen
      manageFocus('modal-content');
      
      // Keyboard Event Listener
      const handleKeyDown = (e: KeyboardEvent) => {
        if (e.key === 'Escape') {
          onClose();
        }
      };

      document.addEventListener('keydown', handleKeyDown);
      return () => document.removeEventListener('keydown', handleKeyDown);
    }
  }, [isOpen, onClose, manageFocus]);

  if (!isOpen) return null;

  return (
    <div 
      className="modal-backdrop" 
      role="dialog" 
      aria-modal="true"
      aria-labelledby="modal-title"
      ref={modalRef}
    >
      <div id="modal-content" className="modal-content" tabIndex={-1}>
        <header className="modal-header">
          <h2 id="modal-title">{title}</h2>
          <button 
            onClick={onClose}
            aria-label={`${title} close`}
            className="close-button"
          >
            ×
          </button>
        </header>
        <div className="modal-body">
          {children}
        </div>
      </div>
    </div>
  );
}