Back to blog

Building Accessible Web Applications: A Complete Guide

June 10, 2024 (1y ago)

Accessibility isn't just a nice-to-have—it's essential. Building accessible web applications ensures everyone can use your product, regardless of their abilities. Here's how to do it right.

Understanding Accessibility

The Four Principles

  1. Perceivable: Information must be presentable in ways users can perceive
  2. Operable: Interface components must be operable
  3. Understandable: Information and operation must be understandable
  4. Robust: Content must be robust enough for various assistive technologies

Legal Requirements

  • WCAG 2.1 AA: Standard for most public websites
  • Section 508: US federal government requirements
  • ADA: Americans with Disabilities Act
  • EN 301 549: European accessibility requirements

Semantic HTML: The Foundation

Use Proper HTML Elements

<!-- Bad: Using divs for everything -->
<div class="header">Navigation</div>
<div class="main">
  <div class="article">Article content</div>
</div>
<div class="footer">Footer</div>

<!-- Good: Semantic HTML -->
<header>Navigation</header>
<main>
  <article>Article content</article>
</main>
<footer>Footer</footer>

Headings Structure

<!-- Proper heading hierarchy -->
<h1>Main Page Title</h1>
<section>
  <h2>Section Title</h2>
  <h3>Subsection Title</h3>
  <p>Content...</p>
</section>
<section>
  <h2>Another Section</h2>
  <h3>Another Subsection</h3>
  <p>More content...</p>
</section>

<!-- Never skip heading levels -->
<h1>Title</h1>
<h3>Bad: Skipped h2</h3>

Lists and Navigation

<!-- Navigation lists -->
<nav aria-label="Main navigation">
  <ul>
    <li><a href="/">Home</a></li>
    <li><a href="/about">About</a></li>
    <li><a href="/contact">Contact</a></li>
  </ul>
</nav>

<!-- Breadcrumb navigation -->
<nav aria-label="Breadcrumb">
  <ol>
    <li><a href="/">Home</a></li>
    <li><a href="/products">Products</a></li>
    <li><span aria-current="page">Current Product</span></li>
  </ol>
</nav>

ARIA Attributes

Landmark Roles

<!-- Define page regions -->
<header role="banner">
  <h1>Site Title</h1>
</header>

<nav role="navigation" aria-label="Main">
  <!-- Navigation content -->
</nav>

<main role="main">
  <!-- Main content -->
</main>

<aside role="complementary" aria-label="Sidebar">
  <!-- Sidebar content -->
</aside>

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

Dynamic Content Updates

<!-- Live regions for dynamic content -->
<div aria-live="polite" id="status-message">
  <!-- Status updates appear here -->
</div>

<div aria-live="assertive" id="error-message">
  <!-- Important error messages -->
</div>

<!-- JavaScript to update live regions -->
function showMessage(message, type = 'polite') {
  const region = document.getElementById(type === 'error' ? 'error-message' : 'status-message');
  region.textContent = message;
  
  // Clear after 5 seconds
  setTimeout(() => {
    region.textContent = '';
  }, 5000);
}

Form Accessibility

<form>
  <fieldset>
    <legend>Contact Information</legend>
    
    <div class="form-group">
      <label for="name">
        Full Name
        <span aria-label="required" aria-hidden="true">*</span>
      </label>
      <input 
        type="text" 
        id="name" 
        name="name" 
        required 
        aria-describedby="name-help"
        aria-invalid="false"
      >
      <div id="name-help" class="help-text">
        Enter your full name as it appears on official documents
      </div>
    </div>
    
    <div class="form-group">
      <label for="email">Email Address</label>
      <input 
        type="email" 
        id="email" 
        name="email" 
        required
        aria-describedby="email-error"
        aria-invalid="true"
      >
      <div id="email-error" class="error-message" role="alert">
        Please enter a valid email address
      </div>
    </div>
    
    <div class="form-group">
      <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>
    </div>
    
    <button type="submit">Submit</button>
  </fieldset>
</form>

Keyboard Navigation

Focus Management

// Focus trap for modals
class FocusTrap {
  constructor(container) {
    this.container = container;
    this.focusableElements = this.getFocusableElements();
    this.firstElement = this.focusableElements[0];
    this.lastElement = this.focusableElements[this.focusableElements.length - 1];
  }
  
  getFocusableElements() {
    return this.container.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
  }
  
  handleKeyDown(e) {
    if (e.key === 'Tab') {
      if (e.shiftKey) {
        if (document.activeElement === this.firstElement) {
          this.lastElement.focus();
          e.preventDefault();
        }
      } else {
        if (document.activeElement === this.lastElement) {
          this.firstElement.focus();
          e.preventDefault();
        }
      }
    }
    
    if (e.key === 'Escape') {
      this.close();
    }
  }
  
  activate() {
    this.firstElement.focus();
    this.container.addEventListener('keydown', this.handleKeyDown.bind(this));
  }
  
  deactivate() {
    this.container.removeEventListener('keydown', this.handleKeyDown.bind(this));
  }
}

// Usage with modal
const modal = document.getElementById('modal');
const focusTrap = new FocusTrap(modal);

modal.classList.add('active');
focusTrap.activate();

Skip Links

<!-- Skip to main content link (visible when focused) -->
<a href="#main-content" class="skip-link">
  Skip to main content
</a>

<!-- Target for skip link -->
<main id="main-content">
  <!-- Main content -->
</main>

<style>
.skip-link {
  position: absolute;
  top: -40px;
  left: 6px;
  background: #000;
  color: white;
  padding: 8px;
  text-decoration: none;
  z-index: 1000;
}

.skip-link:focus {
  top: 6px;
}
</style>

Screen Reader Considerations

Alternative Text for Images

<!-- Meaningful images -->
<img src="chart.png" alt="Sales increased by 25% from Q1 to Q2">

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

<!-- Complex images with detailed descriptions -->
<img src="complex-chart.png" alt="Sales chart showing quarterly data">
<div class="sr-only">
  <p>Detailed description of the chart:</p>
  <ul>
    <li>Q1: $100,000 in sales</li>
    <li>Q2: $125,000 in sales (25% increase)</li>
    <li>Q3: $118,000 in sales (6% decrease)</li>
    <li>Q4: $140,000 in sales (19% increase)</li>
  </ul>
</div>

Data Tables

<table>
  <caption>Monthly Sales Report</caption>
  <thead>
    <tr>
      <th scope="col">Month</th>
      <th scope="col">Sales</th>
      <th scope="col">Target</th>
      <th scope="col">Status</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">January</th>
      <td>$50,000</td>
      <td>$45,000</td>
      <td>✓ Above target</td>
    </tr>
    <tr>
      <th scope="row">February</th>
      <td>$42,000</td>
      <td>$45,000</td>
      <td>✗ Below target</td>
    </tr>
  </tbody>
</table>

Color and Visual Design

Sufficient Contrast

/* Ensure sufficient color contrast */
.text-primary {
  color: #333333; /* Good contrast on white background */
}

.text-secondary {
  color: #666666; /* Still readable */
}

.error-message {
  color: #d32f2f; /* Red with good contrast */
  font-weight: bold; /* Don't rely on color alone */
}

.success-message {
  color: #388e3c; /* Green with good contrast */
  font-weight: bold;
}

/* Focus indicators */
button:focus,
input:focus,
a:focus {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

Don't Rely on Color Alone

<!-- Bad: Only color indicates status -->
<span style="color: red">Error</span>
<span style="color: green">Success</span>

<!-- Good: Color + text/icon -->
<span class="error" aria-label="Error">
  <span aria-hidden="true">✗</span>
  Error
</span>

<span class="success" aria-label="Success">
  <span aria-hidden="true">✓</span>
  Success
</span>

Accessible JavaScript

Component Accessibility

// Accessible accordion component
class AccessibleAccordion {
  constructor(element) {
    this.element = element;
    this.headers = element.querySelectorAll('[data-accordion-header]');
    this.panels = element.querySelectorAll('[data-accordion-panel]');
    this.init();
  }
  
  init() {
    this.headers.forEach((header, index) => {
      const panel = this.panels[index];
      
      // Set up ARIA attributes
      header.setAttribute('aria-expanded', 'false');
      header.setAttribute('aria-controls', panel.id);
      panel.setAttribute('aria-labelledby', header.id);
      panel.setAttribute('hidden', '');
      
      // Add event listeners
      header.addEventListener('click', () => this.toggle(index));
      header.addEventListener('keydown', (e) => this.handleKeydown(e, index));
    });
  }
  
  toggle(index) {
    const header = this.headers[index];
    const panel = this.panels[index];
    const isExpanded = header.getAttribute('aria-expanded') === 'true';
    
    // Update ARIA attributes
    header.setAttribute('aria-expanded', !isExpanded);
    
    if (isExpanded) {
      panel.setAttribute('hidden', '');
    } else {
      panel.removeAttribute('hidden');
    }
  }
  
  handleKeydown(e, index) {
    const { key } = e;
    
    switch (key) {
      case 'Enter':
      case ' ':
        e.preventDefault();
        this.toggle(index);
        break;
      case 'ArrowDown':
        e.preventDefault();
        this.focusNext(index);
        break;
      case 'ArrowUp':
        e.preventDefault();
        this.focusPrevious(index);
        break;
      case 'Home':
        e.preventDefault();
        this.headers[0].focus();
        break;
      case 'End':
        e.preventDefault();
        this.headers[this.headers.length - 1].focus();
        break;
    }
  }
  
  focusNext(currentIndex) {
    const nextIndex = (currentIndex + 1) % this.headers.length;
    this.headers[nextIndex].focus();
  }
  
  focusPrevious(currentIndex) {
    const prevIndex = currentIndex === 0 ? this.headers.length - 1 : currentIndex - 1;
    this.headers[prevIndex].focus();
  }
}

Accessible Modals

<!-- Modal structure -->
<div 
  id="modal" 
  role="dialog" 
  aria-modal="true" 
  aria-labelledby="modal-title"
  aria-describedby="modal-description"
  class="modal"
  hidden
>
  <div class="modal-content">
    <h2 id="modal-title">Confirm Action</h2>
    <p id="modal-description">
      Are you sure you want to delete this item? This action cannot be undone.
    </p>
    
    <div class="modal-actions">
      <button id="cancel-btn" onclick="closeModal()">Cancel</button>
      <button id="confirm-btn" onclick="confirmAction()">Delete</button>
    </div>
    
    <button 
      class="modal-close" 
      aria-label="Close modal"
      onclick="closeModal()"
    >
      ×
    </button>
  </div>
</div>

<!-- JavaScript for modal management -->
function openModal() {
  const modal = document.getElementById('modal');
  modal.removeAttribute('hidden');
  
  // Store previous focus
  const previousFocus = document.activeElement;
  modal.dataset.previousFocus = previousFocus.id;
  
  // Set up focus trap
  const focusTrap = new FocusTrap(modal.querySelector('.modal-content'));
  focusTrap.activate();
  
  // Prevent body scroll
  document.body.style.overflow = 'hidden';
}

function closeModal() {
  const modal = document.getElementById('modal');
  modal.setAttribute('hidden', '');
  
  // Restore focus
  const previousFocusId = modal.dataset.previousFocus;
  if (previousFocusId) {
    document.getElementById(previousFocusId).focus();
  }
  
  // Restore body scroll
  document.body.style.overflow = '';
}

Testing for Accessibility

Automated Testing

// Accessibility testing with axe-core
import axe from 'axe-core';

async function testAccessibility() {
  const results = await axe.run(document.body, {
    rules: {
      'color-contrast': { enabled: true },
      'keyboard-navigation': { enabled: true },
      'aria-labels': { enabled: true }
    }
  });
  
  if (results.violations.length > 0) {
    console.error('Accessibility violations found:', results.violations);
    results.violations.forEach(violation => {
      console.error(`- ${violation.description}`);
      violation.nodes.forEach(node => {
        console.error(`  Element: ${node.target.join(', ')}`);
      });
    });
  } else {
    console.log('No accessibility violations found!');
  }
  
  return results;
}

// Integration with Jest
test('component is accessible', async () => {
  render(<MyComponent />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Manual Testing Checklist

## Accessibility Testing Checklist

### Keyboard Navigation
- [ ] Can I navigate to all interactive elements with Tab?
- [ ] Is there a visible focus indicator?
- [ ] Can I operate all controls with keyboard?
- [ ] Can I escape from modal dialogs with Escape?

### Screen Reader Testing
- [ ] Are all images described?
- [ ] Are form fields properly labeled?
- [ ] Are headings structured logically?
- [ ] Are dynamic changes announced?

### Visual Testing
- [ ] Is text readable at 200% zoom?
- [ ] Is there sufficient color contrast?
- [ ] Can I understand content without color?
- [ ] Are focus indicators visible?

### Cognitive Testing
- [ ] Is language clear and simple?
- [ ] Are error messages helpful?
- [ ] Is navigation predictable?
- [ ] Are timeouts reasonable?

Performance and Accessibility

Optimizing for Screen Readers

// Lazy loading with accessibility in mind
class AccessibleLazyLoad {
  constructor(element, loadFunction) {
    this.element = element;
    this.loadFunction = loadFunction;
    this.observer = null;
    this.init();
  }
  
  init() {
    // Check if user prefers reduced motion
    const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    
    if (prefersReducedMotion) {
      // Load immediately for users who prefer reduced motion
      this.loadContent();
    } else {
      // Use intersection observer for others
      this.setupIntersectionObserver();
    }
  }
  
  setupIntersectionObserver() {
    this.observer = new IntersectionObserver(
      (entries) => {
        entries.forEach(entry => {
          if (entry.isIntersecting) {
            this.loadContent();
            this.observer.unobserve(entry.target);
          }
        });
      },
      { rootMargin: '100px' }
    );
    
    this.observer.observe(this.element);
  }
  
  async loadContent() {
    const content = await this.loadFunction();
    this.element.innerHTML = content;
    
    // Announce to screen readers
    const announcement = document.createElement('div');
    announcement.setAttribute('aria-live', 'polite');
    announcement.setAttribute('aria-atomic', 'true');
    announcement.className = 'sr-only';
    announcement.textContent = 'New content loaded';
    document.body.appendChild(announcement);
    
    setTimeout(() => {
      document.body.removeChild(announcement);
    }, 1000);
  }
}

Best Practices Summary

Development Guidelines

  1. Start with semantic HTML - Use elements for their intended purpose
  2. Add ARIA thoughtfully - Don't overcompensate with ARIA
  3. Test with keyboard - Ensure everything works without a mouse
  4. Provide alternatives - Don't rely on single sensory experiences
  5. Test with real users - Include people with disabilities in testing

Common Mistakes to Avoid

<!-- Don't do this -->
<div onclick="handleClick()">Click me</div>

<!-- Do this instead -->
<button onclick="handleClick()">Click me</button>

<!-- Don't do this -->
<img src="chart.png">

<!-- Do this instead -->
<img src="chart.png" alt="Sales chart showing 25% growth">

<!-- Don't do this -->
<div class="error" style="color: red">Error message</div>

<!-- Do this instead -->
<div class="error" role="alert" aria-live="polite">
  <span aria-hidden="true">⚠️</span>
  Error message
</div>

Tools and Resources

Testing Tools

  • axe DevTools: Browser extension for accessibility testing
  • WAVE: Web accessibility evaluation tool
  • Lighthouse: Built-in accessibility audit
  • Screen readers: NVDA (Windows), VoiceOver (Mac), TalkBack (Android)

Development Resources

  • WebAIM: Comprehensive accessibility resources
  • A11y Project: Community-driven accessibility resource
  • MDN Accessibility: Web accessibility documentation
  • W3C WCAG: Official accessibility guidelines

Conclusion

Accessibility is not a checklist—it's a mindset. Building accessible applications requires empathy, understanding, and continuous learning.

Remember that accessibility benefits everyone:

  • Keyboard navigation helps power users
  • Clear text helps non-native speakers
  • Good contrast helps users in bright environments
  • Captions help users in noisy environments

Start small, test often, and always consider your users. An accessible web is a better web for everyone.