MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Web Accessibility (a11y): Building Inclusive Web Apps

Practical guide to a11y: ARIA roles, keyboard navigation, screen readers, and semantic HTML.

Accessibilitya11yHTMLFrontend

By MinhVo

Introduction

Web accessibility is not an optional feature — it's a fundamental requirement for building applications that serve all users. With over one billion people worldwide living with disabilities, and with accessibility laws becoming increasingly strict (ADA in the US, EAA in the EU, AODA in Canada), building inclusive web applications is both a moral imperative and a legal necessity. Yet many developers still treat accessibility as an afterthought, leading to exclusionary experiences that lock out significant portions of their potential user base.

The good news is that building accessible web applications doesn't have to be difficult or expensive. By following established guidelines like WCAG 2.1, using semantic HTML as your foundation, and testing with assistive technologies, you can create applications that are usable by everyone while also improving the experience for all users — accessibility improvements often benefit people without disabilities too, such as captions in noisy environments or keyboard navigation when a mouse battery dies.

This practical guide goes beyond theory to provide hands-on implementations for the most common accessibility challenges. We'll build real components with proper ARIA attributes, implement keyboard navigation patterns, handle focus management for single-page applications, and set up automated accessibility testing in your CI/CD pipeline.

Inclusive web design

Understanding ARIA Roles and Properties

Landmark Roles

ARIA landmark roles help screen reader users navigate page structure:

<!-- Complete page with landmark roles -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My Application</title>
</head>
<body>
  <!-- Banner landmark -->
  <header role="banner">
    <a href="#main-content" class="skip-link">Skip to main content</a>
    <h1>My Application</h1>
  </header>
  
  <!-- Navigation landmark -->
  <nav role="navigation" aria-label="Main">
    <ul>
      <li><a href="/" aria-current="page">Home</a></li>
      <li><a href="/products">Products</a></li>
      <li><a href="/about">About</a></li>
    </ul>
  </nav>
  
  <!-- Search landmark -->
  <search>
    <form role="search" aria-label="Site search">
      <label for="search-input">Search</label>
      <input type="search" id="search-input" name="q">
      <button type="submit">Search</button>
    </form>
  </search>
  
  <!-- Main content landmark -->
  <main id="main-content" role="main">
    <article>
      <h2>Welcome</h2>
      <p>Content goes here</p>
    </article>
  </main>
  
  <!-- Complementary/aside landmark -->
  <aside role="complementary" aria-label="Related links">
    <h3>Related</h3>
    <ul>
      <li><a href="/help">Help</a></li>
      <li><a href="/contact">Contact</a></li>
    </ul>
  </aside>
  
  <!-- Content info landmark -->
  <footer role="contentinfo">
    <p>&copy; 2024 My Application</p>
  </footer>
</body>
</html>

Widget Roles

// Common widget roles and their usage
const widgetRoles = {
  // Button - use native <button> when possible
  button: {
    role: 'button',
    states: ['aria-pressed', 'aria-expanded', 'aria-disabled'],
    keyboard: ['Enter', 'Space'],
  },
  
  // Checkbox - use native <input type="checkbox"> when possible
  checkbox: {
    role: 'checkbox',
    states: ['aria-checked', 'aria-disabled'],
    keyboard: ['Space'],
  },
  
  // Tab panel
  tab: {
    role: 'tab',
    states: ['aria-selected', 'aria-controls'],
    keyboard: ['ArrowLeft/Right', 'Home', 'End'],
  },
  
  // Dialog/Modal
  dialog: {
    role: 'dialog',
    states: ['aria-modal', 'aria-labelledby', 'aria-describedby'],
    keyboard: ['Escape to close', 'Tab trap'],
  },
  
  // Menu
  menu: {
    role: 'menu',
    states: ['aria-activedescendant'],
    keyboard: ['ArrowUp/Down', 'Home', 'End', 'Escape'],
  },
  
  // Tree view
  tree: {
    role: 'tree',
    states: ['aria-multiselectable', 'aria-activedescendant'],
    keyboard: ['ArrowUp/Down', 'ArrowLeft/Right', 'Home', 'End', '*'],
  },
};

ARIA roles

Building Accessible React Components

Accessible Modal Dialog

import React, { useEffect, useRef, useCallback } from 'react';
 
interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
  description?: string;
}
 
export function Modal({ isOpen, onClose, title, children, description }: ModalProps) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);
  
  // Focus trap
  const handleKeyDown = useCallback((e: KeyboardEvent) => {
    if (!dialogRef.current) return;
    
    // Close on Escape
    if (e.key === 'Escape') {
      onClose();
      return;
    }
    
    // Trap focus
    if (e.key !== 'Tab') return;
    
    const focusableElements = dialogRef.current.querySelectorAll<HTMLElement>(
      'a[href], button:not([disabled]), textarea:not([disabled]), ' +
      'input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
    );
    
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];
    
    if (e.shiftKey) {
      if (document.activeElement === firstElement) {
        e.preventDefault();
        lastElement?.focus();
      }
    } else {
      if (document.activeElement === lastElement) {
        e.preventDefault();
        firstElement?.focus();
      }
    }
  }, [onClose]);
  
  useEffect(() => {
    if (!isOpen) return;
    
    // Store current focus
    previousFocusRef.current = document.activeElement as HTMLElement;
    
    // Add event listener
    document.addEventListener('keydown', handleKeyDown);
    
    // Prevent body scroll
    document.body.style.overflow = 'hidden';
    
    // Focus the dialog
    setTimeout(() => {
      const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      firstFocusable?.focus();
    }, 10);
    
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      document.body.style.overflow = '';
      previousFocusRef.current?.focus();
    };
  }, [isOpen, handleKeyDown]);
  
  if (!isOpen) return null;
  
  return (
    <div
      className="modal-overlay"
      onClick={(e) => {
        if (e.target === e.currentTarget) onClose();
      }}
    >
      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        aria-describedby={description ? 'modal-description' : undefined}
        className="modal-content"
      >
        <div className="modal-header">
          <h2 id="modal-title">{title}</h2>
          <button
            onClick={onClose}
            aria-label="Close dialog"
            className="modal-close"
          >
            <span aria-hidden="true">&times;</span>
          </button>
        </div>
        
        {description && (
          <p id="modal-description" className="modal-description">
            {description}
          </p>
        )}
        
        <div className="modal-body">
          {children}
        </div>
      </div>
    </div>
  );
}

Accessible Dropdown Menu

import React, { useState, useRef, useEffect } from 'react';
 
interface MenuItem {
  id: string;
  label: string;
  icon?: React.ReactNode;
  shortcut?: string;
  disabled?: boolean;
  separator?: boolean;
}
 
interface DropdownMenuProps {
  trigger: React.ReactNode;
  items: MenuItem[];
  onSelect: (id: string) => void;
}
 
export function DropdownMenu({ trigger, items, onSelect }: DropdownMenuProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const menuRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const itemRefs = useRef<(HTMLLIElement | null)[]>([]);
  
  const enabledItems = items.filter(item => !item.disabled && !item.separator);
  
  const openMenu = () => {
    setIsOpen(true);
    setActiveIndex(0);
  };
  
  const closeMenu = () => {
    setIsOpen(false);
    setActiveIndex(-1);
    triggerRef.current?.focus();
  };
  
  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (!isOpen) {
          openMenu();
        } else if (activeIndex >= 0) {
          onSelect(enabledItems[activeIndex].id);
          closeMenu();
        }
        break;
        
      case 'ArrowDown':
        e.preventDefault();
        if (!isOpen) {
          openMenu();
        } else {
          setActiveIndex(prev => {
            const next = prev < enabledItems.length - 1 ? prev + 1 : 0;
            itemRefs.current[next]?.focus();
            return next;
          });
        }
        break;
        
      case 'ArrowUp':
        e.preventDefault();
        if (isOpen) {
          setActiveIndex(prev => {
            const next = prev > 0 ? prev - 1 : enabledItems.length - 1;
            itemRefs.current[next]?.focus();
            return next;
          });
        }
        break;
        
      case 'Home':
        e.preventDefault();
        if (isOpen) {
          setActiveIndex(0);
          itemRefs.current[0]?.focus();
        }
        break;
        
      case 'End':
        e.preventDefault();
        if (isOpen) {
          const lastIndex = enabledItems.length - 1;
          setActiveIndex(lastIndex);
          itemRefs.current[lastIndex]?.focus();
        }
        break;
        
      case 'Escape':
        e.preventDefault();
        closeMenu();
        break;
        
      case 'Tab':
        closeMenu();
        break;
    }
  };
  
  // Close on outside click
  useEffect(() => {
    if (!isOpen) return;
    
    const handleClickOutside = (e: MouseEvent) => {
      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
        closeMenu();
      }
    };
    
    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, [isOpen]);
  
  return (
    <div ref={menuRef} className="dropdown-menu" onKeyDown={handleKeyDown}>
      <button
        ref={triggerRef}
        aria-expanded={isOpen}
        aria-haspopup="menu"
        aria-controls="dropdown-list"
        onClick={() => setIsOpen(!isOpen)}
      >
        {trigger}
      </button>
      
      {isOpen && (
        <ul
          id="dropdown-list"
          role="menu"
          aria-label="Actions"
          className="dropdown-list"
        >
          {items.map((item, index) => {
            if (item.separator) {
              return <li key={item.id} role="separator" className="separator" />;
            }
            
            return (
              <li
                key={item.id}
                ref={el => itemRefs.current[index] = el}
                role="menuitem"
                tabIndex={-1}
                aria-disabled={item.disabled}
                className={`menu-item ${item.disabled ? 'disabled' : ''}`}
                onClick={() => {
                  if (!item.disabled) {
                    onSelect(item.id);
                    closeMenu();
                  }
                }}
              >
                {item.icon && <span className="icon">{item.icon}</span>}
                <span className="label">{item.label}</span>
                {item.shortcut && (
                  <span className="shortcut" aria-hidden="true">
                    {item.shortcut}
                  </span>
                )}
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
}

Accessible Forms in Depth

Form Validation with Live Regions

import React, { useState, useRef } from 'react';
 
interface FormField {
  name: string;
  label: string;
  type: string;
  required?: boolean;
  pattern?: RegExp;
  validate?: (value: string) => string | null;
}
 
interface AccessibleFormProps {
  fields: FormField[];
  onSubmit: (data: Record<string, string>) => Promise<void>;
}
 
export function AccessibleForm({ fields, onSubmit }: AccessibleFormProps) {
  const [formData, setFormData] = useState<Record<string, string>>({});
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
  const errorSummaryRef = useRef<HTMLDivElement>(null);
  
  const validateField = (field: FormField, value: string): string | null => {
    if (field.required && !value.trim()) {
      return `${field.label} is required`;
    }
    
    if (field.pattern && !field.pattern.test(value)) {
      return `${field.label} is not in the correct format`;
    }
    
    if (field.validate) {
      return field.validate(value);
    }
    
    return null;
  };
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    // Validate all fields
    const newErrors: Record<string, string> = {};
    fields.forEach(field => {
      const error = validateField(field, formData[field.name] || '');
      if (error) {
        newErrors[field.name] = error;
      }
    });
    
    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      // Focus error summary for screen readers
      errorSummaryRef.current?.focus();
      return;
    }
    
    setIsSubmitting(true);
    setSubmitStatus('idle');
    
    try {
      await onSubmit(formData);
      setSubmitStatus('success');
    } catch (err) {
      setSubmitStatus('error');
    } finally {
      setIsSubmitting(false);
    }
  };
  
  const errorCount = Object.keys(errors).length;
  
  return (
    <form onSubmit={handleSubmit} noValidate aria-label="Registration form">
      {/* Error summary - announced to screen readers */}
      {errorCount > 0 && (
        <div
          ref={errorSummaryRef}
          role="alert"
          tabIndex={-1}
          className="error-summary"
          aria-labelledby="error-summary-title"
        >
          <h2 id="error-summary-title">
            There {errorCount === 1 ? 'is' : 'are'} {errorCount} error{errorCount !== 1 ? 's' : ''} in the form
          </h2>
          <ul>
            {Object.entries(errors).map(([name, message]) => (
              <li key={name}>
                <a href={`#${name}`}>{message}</a>
              </li>
            ))}
          </ul>
        </div>
      )}
      
      {/* Success/error status */}
      {submitStatus === 'success' && (
        <div role="status" aria-live="polite" className="success-message">
          Form submitted successfully!
        </div>
      )}
      
      {submitStatus === 'error' && (
        <div role="alert" className="error-message">
          There was an error submitting the form. Please try again.
        </div>
      )}
      
      {/* Form fields */}
      {fields.map(field => (
        <div key={field.name} className="form-field">
          <label htmlFor={field.name}>
            {field.label}
            {field.required && (
              <span aria-hidden="true" className="required">*</span>
            )}
          </label>
          
          <input
            id={field.name}
            name={field.name}
            type={field.type}
            value={formData[field.name] || ''}
            onChange={(e) => {
              setFormData(prev => ({ ...prev, [field.name]: e.target.value }));
              // Clear error on change
              if (errors[field.name]) {
                setErrors(prev => {
                  const next = { ...prev };
                  delete next[field.name];
                  return next;
                });
              }
            }}
            aria-required={field.required}
            aria-invalid={!!errors[field.name]}
            aria-describedby={errors[field.name] ? `${field.name}-error` : undefined}
          />
          
          {errors[field.name] && (
            <p id={`${field.name}-error`} role="alert" className="field-error">
              {errors[field.name]}
            </p>
          )}
        </div>
      ))}
      
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}

Accessible forms

Skip Navigation and Focus Management

/* Skip link - visually hidden until focused */
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: #005fcc;
  color: white;
  padding: 8px 16px;
  z-index: 1000;
  font-size: 16px;
  text-decoration: none;
}
 
.skip-link:focus {
  top: 0;
}
<!-- Skip navigation -->
<body>
  <a href="#main-content" class="skip-link">Skip to main content</a>
  <a href="#search" class="skip-link">Skip to search</a>
  
  <header>
    <!-- Navigation -->
  </header>
  
  <main id="main-content" tabindex="-1">
    <!-- Main content -->
  </main>
</body>

Route Change Announcements

import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
 
function RouteAnnouncer() {
  const location = useLocation();
  const [announcement, setAnnouncement] = useState('');
  
  useEffect(() => {
    // Get the page title from the new route
    const pageTitle = document.title;
    setAnnouncement(`Navigated to ${pageTitle}`);
    
    // Clear announcement after screen reader has time to read it
    const timer = setTimeout(() => setAnnouncement(''), 1000);
    return () => clearTimeout(timer);
  }, [location.pathname]);
  
  return (
    <div
      role="status"
      aria-live="assertive"
      aria-atomic="true"
      className="sr-only"
    >
      {announcement}
    </div>
  );
}

Automated Accessibility Testing

ESLint Plugin

{
  "extends": [
    "plugin:jsx-a11y/recommended"
  ],
  "plugins": ["jsx-a11y"],
  "rules": {
    "jsx-a11y/anchor-is-valid": "error",
    "jsx-a11y/click-events-have-key-events": "error",
    "jsx-a11y/no-static-element-interactions": "error",
    "jsx-a11y/role-has-required-aria-props": "error",
    "jsx-a11y/aria-props": "error",
    "jsx-a11y/aria-proptypes": "error",
    "jsx-a11y/aria-unsupported-elements": "error",
    "jsx-a11y/alt-text": "error",
    "jsx-a11y/img-redundant-alt": "error",
    "jsx-a11y/label-has-associated-control": "error"
  }
}

Cypress Accessibility Testing

// cypress/e2e/accessibility.cy.ts
import 'cypress-axe';
 
describe('Accessibility Tests', () => {
  beforeEach(() => {
    cy.visit('/');
    cy.injectAxe();
  });
  
  it('has no accessibility violations on home page', () => {
    cy.checkA11y();
  });
  
  it('has no violations in the navigation', () => {
    cy.get('nav').checkA11y();
  });
  
  it('has no violations in the form', () => {
    cy.get('form').checkA11y();
  });
  
  it('maintains accessibility after interactions', () => {
    // Open modal
    cy.get('[aria-haspopup="dialog"]').click();
    cy.get('[role="dialog"]').should('be.visible');
    
    // Check modal accessibility
    cy.get('[role="dialog"]').checkA11y();
    
    // Close modal
    cy.get('[aria-label="Close dialog"]').click();
    
    // Check page accessibility
    cy.checkA11y();
  });
});

Common Pitfalls and Solutions

PitfallImpactSolution
Missing alt textScreen reader users miss contentAdd descriptive alt text to all images
No keyboard accessMotor-impaired users can't navigateEnsure all interactive elements are keyboard accessible
Poor color contrastLow vision users can't read textUse tools like axe-core to check contrast ratios
Missing form labelsScreen reader users don't know what to enterAlways associate labels with form controls
No focus managementUsers get lost in dynamic contentManage focus on route changes and modal opens
Auto-playing mediaDisrupts screen reader outputNever auto-play audio; provide controls for video

Best Practices for Production

  1. Use semantic HTML first — Native elements provide accessibility for free
  2. Test with screen readers — NVDA (Windows), VoiceOver (Mac), TalkBack (Android)
  3. Implement keyboard navigation — Tab, Arrow keys, Enter, Space, Escape
  4. Manage focus dynamically — Route changes, modals, drawers
  5. Use ARIA sparingly — Only when native HTML isn't sufficient
  6. Test with real users — Include people with disabilities in user testing
  7. Automate testing — Use axe-core, jest-axe, or cypress-axe in CI/CD
  8. Document patterns — Create an accessible component library for your team

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

Building inclusive web applications requires a shift in mindset — accessibility isn't a feature to add at the end, but a quality to build in from the start. By using semantic HTML, implementing proper ARIA attributes, managing focus correctly, and testing with assistive technologies, you can create applications that work for everyone. The investment in accessibility pays dividends not just for users with disabilities, but for all users through better usability, SEO, and code quality.