Web Accessibility (WCAG)

WCAG 2.1 guidelines and implementation checklist

Perceivable

1.1 Text Alternatives

Provide text alternatives for non-text content

<img src="logo.png" alt="Company Logo">

1.2 Time-based Media

Provide alternatives for time-based media

<video><track kind="captions" src="captions.vtt">

1.3 Adaptable

Create content that can be presented in different ways

Use semantic HTML: <main>, <nav>, <section>

1.4 Distinguishable

Make it easier to see and hear content

color: #000; background: #fff; /* 4.5:1 contrast ratio */

Operable

2.1 Keyboard Accessible

Make all functionality available from keyboard

tabindex="0" onkeydown="handleKeyPress(event)"

2.2 Enough Time

Provide users enough time to read and use content

setTimeout(() => {}, 20000); // 20 second timeout

2.3 Seizures

Do not design content that could cause seizures

animation: none; /* Avoid flashing content */

2.4 Navigable

Provide ways to help users navigate

<nav><ul><li><a href="#main">Skip to main content</a></li></ul></nav>

Understandable

3.1 Readable

Make text content readable and understandable

<html lang="en">

3.2 Predictable

Make pages appear and operate in predictable ways

Consistent navigation placement

3.3 Input Assistance

Help users avoid and correct mistakes

<label for="email">Email:</label><input type="email" id="email" required>

Robust

4.1 Compatible

Maximize compatibility with current and future tools

Use valid HTML and proper ARIA attributes

4.2 Assistive Technology

Ensure compatibility with assistive technologies

role="button" aria-label="Close dialog"

Semantic HTML

Use proper heading structure

H1-H6 in logical order

<h1>Main Title</h1><h2>Section</h2><h3>Subsection</h3>

Use semantic elements

Use appropriate HTML5 elements

<main>, <nav>, <section>, <article>, <aside>, <footer>

Use proper form labels

Associate labels with form controls

<label for="name">Name:</label><input id="name" type="text">

Use proper list elements

Use ul, ol, dl for lists

<ul><li>Item 1</li><li>Item 2</li></ul>

ARIA Attributes

Use aria-label

Provide accessible name

<button aria-label="Close dialog">×</button>

Use aria-describedby

Provide additional description

<input aria-describedby="help-text">

Use aria-expanded

Indicate expandable content

<button aria-expanded="false">Menu</button>

Use aria-hidden

Hide decorative content

<div aria-hidden="true">Decoration</div>

Use role attributes

Define element role

role="button", role="dialog", role="navigation"

Keyboard Navigation

Ensure tab order

Logical tab sequence

tabindex="0" for focusable elements

Handle keyboard events

Support Enter, Space, Arrow keys

onkeydown="handleKeyPress(event)"

Visible focus indicators

Clear focus styling

:focus { outline: 2px solid #007bff; }

Skip links

Skip to main content

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

Color and Contrast

Color contrast ratio

4.5:1 for normal text, 3:1 for large text

color: #000; background: #fff; /* 21:1 ratio */

Don't rely on color alone

Use additional indicators

Required fields marked with * and red border

Test with color blindness

Ensure content works in grayscale

Use patterns and icons, not just color

Common Patterns

Accessible Modal Dialog

Proper modal implementation with ARIA

<!-- Modal Trigger -->
<button aria-haspopup="dialog" aria-expanded="false" onclick="openModal()">
  Open Dialog
</button>

<!-- Modal -->
<div id="modal" role="dialog" aria-labelledby="modal-title" aria-describedby="modal-description" aria-hidden="true">
  <div class="modal-content">
    <h2 id="modal-title">Modal Title</h2>
    <p id="modal-description">Modal description</p>
    
    <!-- Close button -->
    <button aria-label="Close dialog" onclick="closeModal()">×</button>
    
    <!-- Modal content -->
    <div class="modal-body">
      <p>Modal content here...</p>
    </div>
  </div>
</div>

<script>
function openModal() {
  const modal = document.getElementById('modal');
  modal.setAttribute('aria-hidden', 'false');
  modal.querySelector('button[aria-label="Close dialog"]').focus();
  
  // Trap focus
  document.addEventListener('keydown', trapFocus);
}

function closeModal() {
  const modal = document.getElementById('modal');
  modal.setAttribute('aria-hidden', 'true');
  document.querySelector('button[aria-haspopup="dialog"]').focus();
  
  // Remove focus trap
  document.removeEventListener('keydown', trapFocus);
}

function trapFocus(e) {
  if (e.key === 'Tab') {
    const focusableElements = document.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];
    
    if (e.shiftKey) {
      if (document.activeElement === firstElement) {
        lastElement.focus();
        e.preventDefault();
      }
    } else {
      if (document.activeElement === lastElement) {
        firstElement.focus();
        e.preventDefault();
      }
    }
  }
}
</script>

Accessible Form Validation

Form validation with proper error handling

<form novalidate>
  <div class="form-group">
    <label for="email">Email Address *</label>
    <input 
      type="email" 
      id="email" 
      name="email" 
      required 
      aria-describedby="email-error email-help"
      aria-invalid="false"
    >
    <div id="email-help" class="help-text">
      Enter a valid email address
    </div>
    <div id="email-error" class="error-text" role="alert" aria-live="polite"></div>
  </div>
  
  <button type="submit">Submit</button>
</form>

<script>
const form = document.querySelector('form');
const emailInput = document.getElementById('email');
const emailError = document.getElementById('email-error');

emailInput.addEventListener('blur', validateEmail);
emailInput.addEventListener('input', clearError);

function validateEmail() {
  const email = emailInput.value;
  const emailRegex = /^[^s@]+@[^s@]+.[^s@]+$/;
  
  if (!email) {
    showError('Email is required');
  } else if (!emailRegex.test(email)) {
    showError('Please enter a valid email address');
  } else {
    clearError();
  }
}

function showError(message) {
  emailInput.setAttribute('aria-invalid', 'true');
  emailError.textContent = message;
  emailError.style.display = 'block';
}

function clearError() {
  emailInput.setAttribute('aria-invalid', 'false');
  emailError.textContent = '';
  emailError.style.display = 'none';
}
</script>

Accessible Navigation Menu

Responsive navigation with proper ARIA

<nav role="navigation" aria-label="Main navigation">
  <button 
    class="menu-toggle" 
    aria-expanded="false" 
    aria-controls="main-menu"
    onclick="toggleMenu()"
  >
    <span class="sr-only">Toggle menu</span>
    <span class="hamburger"></span>
  </button>
  
  <ul id="main-menu" class="nav-menu">
    <li><a href="/" aria-current="page">Home</a></li>
    <li>
      <button 
        aria-expanded="false" 
        aria-haspopup="true"
        onclick="toggleSubmenu(this)"
      >
        Products
        <span class="sr-only">submenu</span>
      </button>
      <ul class="submenu">
        <li><a href="/products/software">Software</a></li>
        <li><a href="/products/hardware">Hardware</a></li>
      </ul>
    </li>
    <li><a href="/about">About</a></li>
    <li><a href="/contact">Contact</a></li>
  </ul>
</nav>

<style>
.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;
}

.nav-menu {
  display: flex;
  list-style: none;
  margin: 0;
  padding: 0;
}

.nav-menu li {
  position: relative;
}

.nav-menu a,
.nav-menu button {
  display: block;
  padding: 1rem;
  text-decoration: none;
  color: inherit;
  background: none;
  border: none;
  cursor: pointer;
}

.nav-menu a:focus,
.nav-menu button:focus {
  outline: 2px solid #007bff;
  outline-offset: 2px;
}

.submenu {
  display: none;
  position: absolute;
  top: 100%;
  left: 0;
  background: white;
  border: 1px solid #ccc;
  list-style: none;
  margin: 0;
  padding: 0;
  min-width: 200px;
}

.submenu[aria-expanded="true"] {
  display: block;
}
</style>

<script>
function toggleMenu() {
  const button = document.querySelector('.menu-toggle');
  const menu = document.getElementById('main-menu');
  const isExpanded = button.getAttribute('aria-expanded') === 'true';
  
  button.setAttribute('aria-expanded', !isExpanded);
  menu.classList.toggle('open');
}

function toggleSubmenu(button) {
  const submenu = button.nextElementSibling;
  const isExpanded = button.getAttribute('aria-expanded') === 'true';
  
  button.setAttribute('aria-expanded', !isExpanded);
  submenu.setAttribute('aria-expanded', !isExpanded);
}
</script>

Accessible Data Table

Table with proper headers and captions

<table>
  <caption>Monthly Sales Report</caption>
  <thead>
    <tr>
      <th scope="col">Month</th>
      <th scope="col">Product A</th>
      <th scope="col">Product B</th>
      <th scope="col">Total</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">January</th>
      <td>$1,000</td>
      <td>$2,000</td>
      <td>$3,000</td>
    </tr>
    <tr>
      <th scope="row">February</th>
      <td>$1,200</td>
      <td>$2,300</td>
      <td>$3,500</td>
    </tr>
    <tr>
      <th scope="row">March</th>
      <td>$1,500</td>
      <td>$2,800</td>
      <td>$4,300</td>
    </tr>
  </tbody>
  <tfoot>
    <tr>
      <th scope="row">Total</th>
      <td>$3,700</td>
      <td>$7,100</td>
      <td>$10,800</td>
    </tr>
  </tfoot>
</table>

<style>
table {
  border-collapse: collapse;
  width: 100%;
  margin: 1rem 0;
}

caption {
  font-weight: bold;
  margin-bottom: 0.5rem;
  text-align: left;
}

th, td {
  border: 1px solid #ddd;
  padding: 0.5rem;
  text-align: left;
}

th {
  background-color: #f5f5f5;
  font-weight: bold;
}

thead th {
  background-color: #e9ecef;
}

tfoot th {
  background-color: #f8f9fa;
  font-weight: bold;
}
</style>

Accessible Image Gallery

Image gallery with proper alt text and descriptions

<div class="gallery" role="region" aria-label="Photo gallery">
  <div class="gallery-controls">
    <button 
      class="prev-btn" 
      aria-label="Previous image"
      onclick="previousImage()"
    >
      ← Previous
    </button>
    <span class="image-counter" aria-live="polite">
      Image 1 of 3
    </span>
    <button 
      class="next-btn" 
      aria-label="Next image"
      onclick="nextImage()"
    >
      Next →
    </button>
  </div>
  
  <div class="gallery-container">
    <img 
      src="image1.jpg" 
      alt="A beautiful sunset over the mountains with orange and pink clouds"
      id="current-image"
      role="img"
      aria-describedby="image-description"
    >
    <div id="image-description" class="image-description">
      A stunning sunset view showing the mountains silhouetted against a colorful sky
    </div>
  </div>
  
  <div class="gallery-thumbnails" role="tablist" aria-label="Gallery thumbnails">
    <button 
      role="tab" 
      aria-selected="true" 
      aria-controls="image1"
      onclick="showImage(0)"
      class="thumbnail active"
    >
      <img src="thumb1.jpg" alt="Sunset thumbnail">
    </button>
    <button 
      role="tab" 
      aria-selected="false" 
      aria-controls="image2"
      onclick="showImage(1)"
      class="thumbnail"
    >
      <img src="thumb2.jpg" alt="Mountain thumbnail">
    </button>
    <button 
      role="tab" 
      aria-selected="false" 
      aria-controls="image3"
      onclick="showImage(2)"
      class="thumbnail"
    >
      <img src="thumb3.jpg" alt="Forest thumbnail">
    </button>
  </div>
</div>

<script>
const images = [
  {
    src: 'image1.jpg',
    alt: 'A beautiful sunset over the mountains with orange and pink clouds',
    description: 'A stunning sunset view showing the mountains silhouetted against a colorful sky'
  },
  {
    src: 'image2.jpg',
    alt: 'Snow-capped mountain peaks against a clear blue sky',
    description: 'Majestic mountain peaks covered in snow, reaching into the clear blue sky'
  },
  {
    src: 'image3.jpg',
    alt: 'Dense green forest with sunlight filtering through the trees',
    description: 'A peaceful forest scene with sunlight creating beautiful patterns through the trees'
  }
];

let currentIndex = 0;

function showImage(index) {
  currentIndex = index;
  const image = images[index];
  const imgElement = document.getElementById('current-image');
  const description = document.getElementById('image-description');
  const counter = document.querySelector('.image-counter');
  
  imgElement.src = image.src;
  imgElement.alt = image.alt;
  description.textContent = image.description;
  counter.textContent = `Image ${index + 1} of ${images.length}`;
  
  // Update thumbnails
  document.querySelectorAll('.thumbnail').forEach((thumb, i) => {
    thumb.setAttribute('aria-selected', i === index);
    thumb.classList.toggle('active', i === index);
  });
}

function nextImage() {
  const nextIndex = (currentIndex + 1) % images.length;
  showImage(nextIndex);
}

function previousImage() {
  const prevIndex = (currentIndex - 1 + images.length) % images.length;
  showImage(prevIndex);
}
</script>

Tips & Best Practices

Test with screen readers like NVDA, JAWS, or VoiceOver

Use automated tools like axe-core, Lighthouse, or WAVE

Ensure keyboard navigation works for all interactive elements

Provide sufficient color contrast (4.5:1 for normal text)

Use semantic HTML and proper heading structure