Back to Blog

Building Accessible Web Applications

7 min read

Best practices for creating inclusive web experiences that work for everyone.

Accessibilitya11yBest PracticesWCAG

Why Accessibility Matters

Accessibility isn't just about compliance or checking boxes—it's about creating web experiences that work for everyone, regardless of their abilities. Here's why it should be a priority:

  • 1 billion+ people worldwide have disabilities
  • Good accessibility = better UX for everyone
  • Legal requirement in many jurisdictions
  • Better SEO (semantic HTML helps search engines)
  • Larger audience reach

The POUR Principles

WCAG is built on four principles. Content must be:

  1. Perceivable - Users can perceive the information
  2. Operable - Users can operate the interface
  3. Understandable - Users can understand the information
  4. Robust - Content works across technologies

Practical Implementation

1. Semantic HTML

Use the right HTML elements for the job:

<!-- ❌ Bad -->
<div onclick="handleClick()">Click me</div>

<!-- ✅ Good -->
<button onClick={handleClick}>Click me</button>
<!-- ❌ Bad -->
<div class="heading">Page Title</div>

<!-- ✅ Good -->
<h1>Page Title</h1>

Why it matters:

  • Screen readers use semantic HTML for navigation
  • Keyboard navigation works automatically
  • Better SEO and document structure

2. Keyboard Navigation

Every interactive element must be keyboard accessible:

function AccessibleDropdown() {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <div>
      <button
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={(e) => {
          if (e.key === 'Enter' || e.key === ' ') {
            e.preventDefault();
            setIsOpen(!isOpen);
          }
        }}
        aria-expanded={isOpen}
        aria-haspopup="true"
      >
        Menu
      </button>
      
      {isOpen && (
        <ul role="menu">
          <li role="menuitem">
            <a href="/home">Home</a>
          </li>
          <li role="menuitem">
            <a href="/about">About</a>
          </li>
        </ul>
      )}
    </div>
  );
}

Test it: Tab through your entire site without a mouse.

3. Color Contrast

Ensure sufficient contrast between text and background:

/* ❌ Bad - Contrast ratio 2.1:1 */
.text {
  color: #999;
  background: #fff;
}

/* ✅ Good - Contrast ratio 7:1 */
.text {
  color: #333;
  background: #fff;
}

/* ✅ Good - Contrast ratio 12:1 */
.text-dark {
  color: #fff;
  background: #0b0d10;
}

WCAG Requirements:

  • Normal text: 4.5:1 minimum (AA)
  • Large text (18pt+): 3:1 minimum (AA)
  • Enhanced: 7:1 (AAA)

Tool: Use WebAIM Contrast Checker

4. Alternative Text

Provide meaningful alt text for images:

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

// ✅ Good - Descriptive
<img 
  src="chart.png" 
  alt="Bar chart showing 45% increase in revenue from Q1 to Q2 2024"
/>

// ✅ Decorative images
<img src="decoration.png" alt="" role="presentation" />

// ✅ Complex images
<figure>
  <img 
    src="complex-chart.png"
    alt="Detailed sales data chart" 
  />
  <figcaption>
    Sales increased 45% from $2M in Q1 to $2.9M in Q2 2024.
    Top performing region was Asia Pacific with 68% growth.
  </figcaption>
</figure>

5. ARIA Labels and Roles

Use ARIA to enhance semantics when HTML isn't enough:

// Icon buttons need labels
<button aria-label="Close menu">
  <X aria-hidden="true" />
</button>

// Form inputs need labels
<div>
  <label htmlFor="email">Email Address</label>
  <input 
    id="email"
    type="email"
    aria-required="true"
    aria-describedby="email-hint"
  />
  <span id="email-hint">
    We'll never share your email
  </span>
</div>

// Loading states
<button disabled aria-busy="true">
  <Spinner aria-hidden="true" />
  Loading...
</button>

// Live regions for dynamic content
<div aria-live="polite" aria-atomic="true">
  {message}
</div>

6. Focus Management

Make focus visible and manage it properly:

/* ✅ Clear focus indicators */
:focus-visible {
  outline: 2px solid #22d3ee;
  outline-offset: 2px;
  border-radius: 4px;
}

/* ❌ Never do this without an alternative */
:focus {
  outline: none; /* Removes focus indicator! */
}
// Focus management in modals
function Modal({ isOpen, onClose, children }: ModalProps) {
  const closeButtonRef = useRef<HTMLButtonElement>(null);
  
  useEffect(() => {
    if (isOpen) {
      // Focus close button when modal opens
      closeButtonRef.current?.focus();
    }
  }, [isOpen]);
  
  if (!isOpen) return null;
  
  return (
    <div 
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
    >
      <h2 id="modal-title">Modal Title</h2>
      {children}
      <button 
        ref={closeButtonRef}
        onClick={onClose}
      >
        Close
      </button>
    </div>
  );
}

Allow keyboard users to skip navigation:

// Add at the very top of your app
export function Layout({ children }) {
  return (
    <>
      <a 
        href="#main-content"
        className="skip-link"
      >
        Skip to main content
      </a>
      <header>
        {/* Navigation */}
      </header>
      <main id="main-content">
        {children}
      </main>
    </>
  );
}
.skip-link {
  position: absolute;
  left: -9999px;
  z-index: 999;
  padding: 1rem;
  background: #22d3ee;
  color: #0b0d10;
  text-decoration: none;
}

.skip-link:focus {
  left: 50%;
  transform: translateX(-50%);
  top: 1rem;
}

Form Accessibility

Forms are critical—make them accessible:

function AccessibleForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});
  
  return (
    <form>
      <div>
        <label htmlFor="name">
          Name *
        </label>
        <input
          id="name"
          type="text"
          required
          aria-required="true"
          aria-invalid={!!errors.name}
          aria-describedby={errors.name ? "name-error" : undefined}
        />
        {errors.name && (
          <span 
            id="name-error" 
            role="alert"
            className="error"
          >
            {errors.name}
          </span>
        )}
      </div>
      
      <fieldset>
        <legend>Subscription Type</legend>
        <div>
          <input 
            type="radio" 
            id="free" 
            name="plan"
            value="free"
          />
          <label htmlFor="free">Free</label>
        </div>
        <div>
          <input 
            type="radio" 
            id="pro" 
            name="plan"
            value="pro"
          />
          <label htmlFor="pro">Pro</label>
        </div>
      </fieldset>
      
      <button type="submit">
        Submit
      </button>
    </form>
  );
}

Testing for Accessibility

Automated Testing

# Install axe-core
npm install --save-dev @axe-core/react
// In development
if (process.env.NODE_ENV !== 'production') {
  import('@axe-core/react').then((axe) => {
    axe.default(React, ReactDOM, 1000);
  });
}

Manual Testing Checklist

Essential Tests:

  1. Keyboard only - Can you navigate the entire site with just keyboard?
  2. Screen reader - Test with NVDA (Windows) or VoiceOver (Mac)
  3. Zoom to 200% - Does the site still work?
  4. Color blindness - Use a simulator
  5. Automated tools - Run Lighthouse, axe DevTools
  6. Reduce motion - Test with prefers-reduced-motion

Respect User Preferences

/* Reduce motion for users who prefer it */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

/* High contrast mode */
@media (prefers-contrast: high) {
  .card {
    border: 2px solid currentColor;
  }
}

/* Dark mode */
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0b0d10;
    --fg: #e6eaf2;
  }
}

Common Mistakes to Avoid

1. Clickable Divs

// ❌ Bad - Not keyboard accessible, no semantics
<div onClick={handleClick} className="button">
  Click me
</div>

// ✅ Good
<button onClick={handleClick}>
  Click me
</button>

2. Missing Labels

// ❌ Bad
<input type="text" placeholder="Enter name" />

// ✅ Good
<label htmlFor="name">Name</label>
<input id="name" type="text" placeholder="Enter name" />

3. Auto-Playing Media

// ❌ Bad - Violates WCAG
<video autoplay />

// ✅ Good - User controls
<video controls>
  <source src="video.mp4" type="video/mp4" />
  <track 
    kind="captions" 
    src="captions.vtt" 
    srclang="en" 
    label="English"
  />
</video>

4. Inaccessible Modals

// ❌ Bad - Focus not trapped, no aria
<div className="modal">
  <div>Content</div>
</div>

// ✅ Good - Proper modal
function AccessibleModal({ isOpen, onClose, children }) {
  useEffect(() => {
    if (!isOpen) return;
    
    // Trap focus
    const focusableElements = modalRef.current.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];
    
    function handleTab(e: KeyboardEvent) {
      if (e.key !== 'Tab') return;
      
      if (e.shiftKey && document.activeElement === firstElement) {
        e.preventDefault();
        lastElement.focus();
      } else if (!e.shiftKey && document.activeElement === lastElement) {
        e.preventDefault();
        firstElement.focus();
      }
    }
    
    document.addEventListener('keydown', handleTab);
    return () => document.removeEventListener('keydown', handleTab);
  }, [isOpen]);
  
  if (!isOpen) return null;
  
  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
    >
      <h2 id="modal-title">Title</h2>
      {children}
      <button onClick={onClose}>Close</button>
    </div>
  );
}

Resources

Conclusion

Building accessible websites isn't optional—it's essential. Start with these fundamentals:

  1. Use semantic HTML
  2. Ensure keyboard accessibility
  3. Provide text alternatives
  4. Test with real assistive technologies
  5. Make it a team priority

Remember: Accessibility benefits everyone, not just people with disabilities. A more accessible web is a better web.


Questions about accessibility? Reach out - I'm happy to help!