Back to Blog
Building Accessible Web Applications: A Practical Guide
AccessibilityWeb DevelopmentInclusive DesignUX

Building Accessible Web Applications: A Practical Guide

Web accessibility isn't just about compliance—it's about creating inclusive experiences. Learn practical techniques to make your applications usable by everyone.

Dec 08, 2024
12 min read
Sharath Devadiga

Building Accessible Web Applications: A Practical Guide

Web accessibility isn't just about compliance—it's about creating inclusive experiences that work for everyone. After auditing and improving accessibility for several projects, I've learned that accessibility improvements often make applications better for all users, not just those with disabilities.

Why Accessibility Matters

The Business Case

  • Legal Requirements: Many countries have laws requiring digital accessibility
  • Market Reach: 15% of the global population has some form of disability
  • SEO Benefits: Many accessibility practices improve search engine rankings
  • Better UX: Accessible design benefits everyone
  • Personal Impact

    Working on accessibility has made me a better developer. It forces you to think about edge cases, consider different user contexts, and write more semantic code.

    Foundation: Semantic HTML

    The most important accessibility improvement is using proper HTML elements.

    Before and After

    html
    <!-- Bad: Using divs for everything -->
    <div class="button" onclick="handleClick()">Click Me</div>
    <div class="heading">Page Title</div>
    <div class="input-container">
      <div class="label">Email</div>
      <div class="input" contenteditable="true"></div>
    </div>
    
    <!-- Good: Using semantic elements -->
    <button type="button" onclick="handleClick()">Click Me</button>
    <h1>Page Title</h1>
    <div class="input-container">
      <label for="email">Email</label>
      <input type="email" id="email" name="email" />
    </div>

    Form Accessibility

    Forms are where accessibility really matters:

    html
    <form>
      <div class="form-group">
        <label for="email">
          Email Address 
          <span class="required">*</span>
        </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">
          We'll never share your email with anyone else.
        </div>
        <div id="email-error" class="error-message" role="alert">
          <!-- Error message appears here -->
        </div>
      </div>
      
      <fieldset>
        <legend>Preferred Contact Method</legend>
        <div class="radio-group">
          <input type="radio" id="contact-email" name="contact" value="email" />
          <label for="contact-email">Email</label>
        </div>
        <div class="radio-group">
          <input type="radio" id="contact-phone" name="contact" value="phone" />
          <label for="contact-phone">Phone</label>
        </div>
      </fieldset>
    </form>

    ARIA: When Semantic HTML Isn't Enough

    ARIA (Accessible Rich Internet Applications) attributes help when you need to go beyond standard HTML elements.

    Custom Components

    Here's how I made a custom dropdown accessible:

    javascript
    function AccessibleDropdown({ options, value, onChange, label }) {
      const [isOpen, setIsOpen] = useState(false);
      const [focusedIndex, setFocusedIndex] = useState(-1);
      const buttonRef = useRef(null);
      const listRef = useRef(null);
    
      const handleKeyDown = (event) => {
        switch (event.key) {
          case 'ArrowDown':
            event.preventDefault();
            if (!isOpen) {
              setIsOpen(true);
            } else {
              setFocusedIndex(prev => 
                prev < options.length - 1 ? prev + 1 : 0
              );
            }
            break;
            
          case 'ArrowUp':
            event.preventDefault();
            if (isOpen) {
              setFocusedIndex(prev => 
                prev > 0 ? prev - 1 : options.length - 1
              );
            }
            break;
            
          case 'Enter':
          case ' ':
            event.preventDefault();
            if (isOpen && focusedIndex >= 0) {
              onChange(options[focusedIndex]);
              setIsOpen(false);
              buttonRef.current?.focus();
            } else {
              setIsOpen(!isOpen);
            }
            break;
            
          case 'Escape':
            setIsOpen(false);
            buttonRef.current?.focus();
            break;
        }
      };
    
      return (
        <div className="dropdown">
          <button
            ref={buttonRef}
            type="button"
            aria-haspopup="listbox"
            aria-expanded={isOpen}
            aria-labelledby="dropdown-label"
            onClick={() => setIsOpen(!isOpen)}
            onKeyDown={handleKeyDown}
            className="dropdown-button"
          >
            {value || 'Select an option'}
          </button>
          
          <div id="dropdown-label" className="dropdown-label">
            {label}
          </div>
          
          {isOpen && (
            <ul
              ref={listRef}
              role="listbox"
              aria-labelledby="dropdown-label"
              className="dropdown-list"
            >
              {options.map((option, index) => (
                <li
                  key={option.value}
                  role="option"
                  aria-selected={option.value === value}
                  className={`dropdown-option ${
                    index === focusedIndex ? 'focused' : ''
                  }`}
                  onClick={() => {
                    onChange(option);
                    setIsOpen(false);
                    buttonRef.current?.focus();
                  }}
                >
                  {option.label}
                </li>
              ))}
            </ul>
          )}
        </div>
      );
    }

    Live Regions

    For dynamic content updates:

    javascript
    function StatusMessage({ message, type = 'polite' }) {
      return (
        <div
          role="status"
          aria-live={type}
          aria-atomic="true"
          className="sr-only"
        >
          {message}
        </div>
      );
    }
    
    // Usage
    function FormWithValidation() {
      const [status, setStatus] = useState('');
      
      const handleSubmit = async (formData) => {
        setStatus('Submitting form...');
        
        try {
          await submitForm(formData);
          setStatus('Form submitted successfully!');
        } catch (error) {
          setStatus('Error submitting form. Please try again.');
        }
      };
    
      return (
        <form onSubmit={handleSubmit}>
          {/* Form fields */}
          <StatusMessage message={status} />
        </form>
      );
    }

    Keyboard Navigation

    Ensuring your application works with keyboard-only navigation is crucial.

    Focus Management

    javascript
    function Modal({ isOpen, onClose, children }) {
      const modalRef = useRef(null);
      const previousFocusRef = useRef(null);
    
      useEffect(() => {
        if (isOpen) {
          // Store previously focused element
          previousFocusRef.current = document.activeElement;
          
          // Focus first focusable element in modal
          const firstFocusable = modalRef.current?.querySelector(
            'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
          );
          firstFocusable?.focus();
    
          // Trap focus within modal
          const handleKeyDown = (event) => {
            if (event.key === 'Tab') {
              const focusableElements = Array.from(
                modalRef.current.querySelectorAll(
                  'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
                )
              );
              
              const firstElement = focusableElements[0];
              const lastElement = focusableElements[focusableElements.length - 1];
    
              if (event.shiftKey && document.activeElement === firstElement) {
                event.preventDefault();
                lastElement.focus();
              } else if (!event.shiftKey && document.activeElement === lastElement) {
                event.preventDefault();
                firstElement.focus();
              }
            } else if (event.key === 'Escape') {
              onClose();
            }
          };
    
          document.addEventListener('keydown', handleKeyDown);
          
          return () => {
            document.removeEventListener('keydown', handleKeyDown);
          };
        }
      }, [isOpen, onClose]);
    
      useEffect(() => {
        if (!isOpen && previousFocusRef.current) {
          // Return focus to previously focused element
          previousFocusRef.current.focus();
        }
      }, [isOpen]);
    
      if (!isOpen) return null;
    
      return (
        <div className="modal-overlay" onClick={onClose}>
          <div
            ref={modalRef}
            className="modal"
            role="dialog"
            aria-modal="true"
            onClick={(e) => e.stopPropagation()}
          >
            {children}
          </div>
        </div>
      );
    }

    Skip Links

    Skip links help keyboard users navigate quickly:

    css
    .skip-link {
      position: absolute;
      top: -40px;
      left: 6px;
      background: #000;
      color: #fff;
      padding: 8px;
      text-decoration: none;
      border-radius: 0 0 4px 4px;
      z-index: 1000;
      transition: top 0.3s;
    }
    
    .skip-link:focus {
      top: 0;
    }

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

    Color and Contrast

    Proper color contrast is essential for users with visual impairments.

    Contrast Requirements

  • Normal text: 4.5:1 contrast ratio
  • Large text: 3:1 contrast ratio
  • UI components: 3:1 contrast ratio
  • CSS for Better Contrast

    css
    :root {
      /* Ensure sufficient contrast ratios */
      --text-primary: #1a1a1a;
      --text-secondary: #4a4a4a;
      --bg-primary: #ffffff;
      --bg-secondary: #f8f9fa;
      --accent: #2563eb;
      --accent-hover: #1d4ed8;
    }
    
    /* High contrast mode support */
    @media (prefers-contrast: high) {
      :root {
        --text-primary: #000000;
        --text-secondary: #000000;
        --bg-primary: #ffffff;
        --accent: #0000ff;
      }
    }
    
    /* Don't rely on color alone */
    .status-success {
      color: #059669;
    }
    
    .status-success::before {
      content: "✓ ";
      font-weight: bold;
    }
    
    .status-error {
      color: #dc2626;
    }
    
    .status-error::before {
      content: "✗ ";
      font-weight: bold;
    }

    Focus Indicators

    css
    /* Custom focus styles */
    .btn:focus,
    .form-input:focus,
    .link:focus {
      outline: 2px solid #2563eb;
      outline-offset: 2px;
      border-radius: 4px;
    }
    
    /* Focus within for complex components */
    .card:focus-within {
      box-shadow: 0 0 0 2px #2563eb;
    }

    Images and Media

    Alternative Text

    html
    <!-- Informative images -->
    <img src="chart.png" alt="Sales increased 25% from Q1 to Q2" />
    
    <!-- Decorative images -->
    <img src="decoration.png" alt="" />
    
    <!-- Complex images -->
    <img src="complex-chart.png" alt="Quarterly sales data" 
         aria-describedby="chart-description" />
    <div id="chart-description">
      <p>Detailed breakdown of quarterly sales showing...</p>
    </div>

    Video Accessibility

    html
    <video controls>
      <source src="video.mp4" type="video/mp4" />
      <track kind="captions" src="captions.vtt" srclang="en" 
             label="English" default />
      <track kind="descriptions" src="descriptions.vtt" srclang="en" 
             label="English descriptions" />
      <p>Your browser doesn't support HTML video.</p>
    </video>

    Testing Your Accessibility

    Automated Testing

    javascript
    // Using @axe-core/react
    import { axe, toHaveNoViolations } from 'jest-axe';
    
    expect.extend(toHaveNoViolations);
    
    test('should not have accessibility violations', async () => {
      const { container } = render(<MyComponent />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    Manual Testing Checklist

  • Keyboard Navigation
  • - Tab through all interactive elements

    - Verify focus is visible and logical

    - Test with screen reader navigation

  • Screen Reader Testing
  • - Use NVDA (Windows), VoiceOver (Mac), or ORCA (Linux)

    - Verify content is announced correctly

    - Check landmark navigation

  • Color and Contrast
  • - Use WebAIM's contrast checker

    - Test with high contrast mode

    - Verify information isn't conveyed by color alone

    Performance and Accessibility

    Reduce Motion

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

    Lazy Loading with Accessibility

    javascript
    function AccessibleImage({ src, alt, ...props }) {
      const [isLoaded, setIsLoaded] = useState(false);
      const [hasError, setHasError] = useState(false);
    
      return (
        <div className="image-container">
          {!isLoaded && !hasError && (
            <div className="image-placeholder" aria-label="Loading image">
              <span className="spinner" aria-hidden="true"></span>
            </div>
          )}
          
          <img
            src={src}
            alt={alt}
            onLoad={() => setIsLoaded(true)}
            onError={() => setHasError(true)}
            style={{ opacity: isLoaded ? 1 : 0 }}
            {...props}
          />
          
          {hasError && (
            <div className="error-state" role="img" aria-label={alt}>
              <span>Failed to load image: {alt}</span>
            </div>
          )}
        </div>
      );
    }

    Tools and Resources

    Helpful Tools

  • axe DevTools: Browser extension for accessibility testing
  • WAVE: Web accessibility evaluation tool
  • Lighthouse: Includes accessibility audits
  • Color Oracle: Color blindness simulator
  • Screen reader: NVDA (free), VoiceOver (built-in Mac), ORCA (Linux)
  • Testing Strategy

  • During Development: Use axe DevTools and ESLint accessibility plugins
  • Before Release: Run automated tests and manual keyboard testing
  • After Release: Monitor real user feedback and conduct periodic audits
  • Conclusion

    Accessibility is not a feature you add at the end—it's a fundamental part of good web development. By following these practices, you'll create applications that work for everyone and often perform better overall.

    Key takeaways:

  • Start with semantic HTML
  • Test with keyboard navigation
  • Ensure proper color contrast
  • Use ARIA thoughtfully, not excessively
  • Test with real assistive technologies
  • Make accessibility part of your development process
  • Remember: accessibility benefits everyone, not just people with disabilities. A well-designed accessible interface is simply a well-designed interface.

    The investment in learning accessibility pays dividends in code quality, user satisfaction, and professional growth. Your users will thank you for it.

    SD

    Sharath Devadiga

    Software Developer passionate about creating efficient, user-friendly web applications. Currently building projects with React, Node.js, and modern JavaScript technologies.