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 Interfaces

Build accessible web apps: ARIA, semantic HTML, keyboard navigation, and screen readers.

Accessibilitya11yHTMLFrontend

By MinhVo

Introduction

Web accessibility (often abbreviated as a11y, where 11 represents the number of letters between 'a' and 'y' in "accessibility") is the practice of ensuring that websites and web applications are usable by everyone, including people with disabilities. According to the World Health Organization, approximately 1.3 billion people worldwide live with some form of disability, representing about 16% of the global population. Building accessible interfaces isn't just the right thing to do — it's also a legal requirement in many jurisdictions and good business practice.

Accessibility encompasses a wide range of disabilities, including visual impairments (blindness, low vision, color blindness), motor impairments (limited dexterity, paralysis), cognitive impairments (learning disabilities, attention disorders), and auditory impairments (deafness, hard of hearing). Each type of disability requires different accommodations, and a truly accessible application addresses all of them.

In this comprehensive guide, we'll cover the fundamental principles of web accessibility, dive deep into ARIA attributes and semantic HTML, explore keyboard navigation patterns, examine screen reader compatibility, and provide practical code examples for building inclusive interfaces. Whether you're new to accessibility or looking to deepen your expertise, this guide will equip you with the knowledge to create web experiences that work for everyone.

Web accessibility

Understanding Accessibility: Core Concepts

The POUR Principles

The Web Content Accessibility Guidelines (WCAG) are organized around four principles, known by the acronym POUR:

  1. Perceivable: Information and UI components must be presentable to users in ways they can perceive
  2. Operable: UI components and navigation must be operable by all users
  3. Understandable: Information and the operation of the UI must be understandable
  4. Robust: Content must be robust enough to be interpreted by a wide variety of user agents, including assistive technologies

WCAG Conformance Levels

WCAG defines three levels of conformance:

LevelDescriptionLegal Requirements
AMinimum accessibilityBaseline requirement
AAStandard accessibilityMost common legal requirement
AAAOptimal accessibilityHighest level, often aspirational

Types of Disabilities and Accommodations

// Categories of accessibility accommodations
const accessibilityAccommodations = {
  visual: {
    blindness: ['Screen readers', 'Braille displays', 'Audio descriptions'],
    lowVision: ['Zoom/magnification', 'High contrast', 'Large text'],
    colorBlindness: ['Color alternatives', 'Patterns/textures', 'Color-blind safe palettes'],
  },
  motor: {
    limitedDexterity: ['Keyboard navigation', 'Voice control', 'Switch devices'],
    paralysis: ['Eye tracking', 'Head tracking', 'Sip-and-puff devices'],
  },
  cognitive: {
    learningDisabilities: ['Simple language', 'Clear layout', 'Consistent navigation'],
    attentionDisorders: ['Minimal distractions', 'Clear focus indicators', 'Predictable interactions'],
  },
  auditory: {
    deafness: ['Captions', 'Sign language', 'Visual alerts'],
    hardOfHearing: ['Volume controls', 'Clear audio', 'Transcripts'],
  },
};

Semantic HTML: The Foundation of Accessibility

Why Semantic HTML Matters

Semantic HTML is the single most important factor in web accessibility. Screen readers and other assistive technologies rely on the semantic meaning of HTML elements to convey information to users.

<!-- Bad: Non-semantic markup -->
<div class="header">
  <div class="nav">
    <div class="nav-item" onclick="navigate('/')">Home</div>
    <div class="nav-item" onclick="navigate('/about')">About</div>
  </div>
</div>
<div class="main">
  <div class="article">
    <div class="title">My Article</div>
    <div class="content">...</div>
  </div>
</div>
 
<!-- Good: Semantic markup -->
<header>
  <nav aria-label="Main navigation">
    <ul>
      <li><a href="/">Home</a></li>
      <li><a href="/about">About</a></li>
    </ul>
  </nav>
</header>
<main>
  <article>
    <h1>My Article</h1>
    <p>...</p>
  </article>
</main>

Essential Semantic Elements

<!-- Page structure -->
<header>     <!-- Banner/branding area -->
<nav>        <!-- Navigation section -->
<main>       <!-- Primary content (only one per page) -->
<aside>      <!-- Sidebar/complementary content -->
<footer>     <!-- Footer information -->
<section>    <!-- Thematic grouping of content -->
<article>    <!-- Self-contained composition -->
<figure>     <!-- Illustrative content -->
<figcaption> <!-- Caption for figure -->
 
<!-- Text semantics -->
<h1>-<h6>   <!-- Headings (proper hierarchy) -->
<p>          <!-- Paragraphs -->
<blockquote> <!-- Quoted content -->
<cite>       <!-- Citation/reference -->
<code>       <!-- Code snippets -->
<em>         <!-- Emphasis (screen readers change tone) -->
<strong>     <!-- Strong importance -->
<mark>       <!-- Highlighted text -->
<time>       <!-- Dates and times -->
<abbr>       <!-- Abbreviations -->
 
<!-- Interactive elements -->
<a>          <!-- Links (focusable, clickable) -->
<button>     <!-- Buttons (focusable, clickable) -->
<input>      <!-- Form inputs -->
<select>     <!-- Dropdown selects -->
<textarea>   <!-- Multi-line text input -->
<label>      <!-- Labels for form controls -->
<fieldset>   <!-- Group related form elements -->
<legend>     <!-- Caption for fieldset -->

Semantic HTML

Proper Heading Hierarchy

<!-- Good: Proper heading hierarchy -->
<h1>Website Name</h1>
  <h2>Section 1</h2>
    <h3>Subsection 1.1</h3>
    <h3>Subsection 1.2</h3>
  <h2>Section 2</h2>
    <h3>Subsection 2.1</h3>
      <h4>Detail 2.1.1</h4>
 
<!-- Bad: Skipping heading levels -->
<h1>Website Name</h1>
<h3>Section 1</h3>  <!-- Skipped h2 -->
<h5>Subsection</h5> <!-- Skipped h4 -->

ARIA: Accessible Rich Internet Applications

When to Use ARIA

The first rule of ARIA is: Don't use ARIA if you can use native HTML elements. ARIA should only be used when native HTML doesn't provide the necessary semantics.

<!-- Bad: Using ARIA when native HTML works -->
<div role="button" tabindex="0" onclick="handleClick()">Click me</div>
 
<!-- Good: Using native button -->
<button onclick="handleClick()">Click me</button>
 
<!-- Appropriate ARIA use: Custom widget without native equivalent -->
<div role="tablist" aria-label="Settings">
  <button role="tab" aria-selected="true" aria-controls="panel-1">General</button>
  <button role="tab" aria-selected="false" aria-controls="panel-2">Security</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">...</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>...</div>

Common ARIA Attributes

// ARIA attribute categories
const ariaAttributes = {
  // Widget attributes
  role: 'Defines the type of UI element',
  ariaChecked: 'State of checkboxes, radio buttons, switches',
  ariaDisabled: 'Indicates element is perceivable but not operable',
  ariaExpanded: 'State of expandable elements',
  ariaHidden: 'Removes element from accessibility tree',
  ariaPressed: 'State of toggle buttons',
  ariaSelected: 'State of selectable elements',
  
  // Live region attributes
  ariaLive: 'Indicates content that updates dynamically',
  ariaAtomic: 'Whether entire region should be announced',
  ariaRelevant: 'What changes should be announced',
  ariaBusy: 'Indicates region is being updated',
  
  // Relationship attributes
  ariaLabel: 'Accessible label when visible text is insufficient',
  ariaLabelledby: 'ID of element that labels this element',
  ariaDescribedby: 'ID of element that describes this element',
  ariaControls: 'ID of element this element controls',
  ariaOwns: 'ID of element this element owns',
  
  // Drag and drop
  ariaGrabbed: 'State of draggable element',
  ariaDropeffect: 'What happens when element is dropped',
};

Live Regions for Dynamic Content

<!-- Polite announcements (waits for screen reader to finish) -->
<div aria-live="polite" aria-atomic="true">
  <p>Search results: 42 items found</p>
</div>
 
<!-- Assertive announcements (interrupts screen reader) -->
<div aria-live="assertive" role="alert">
  <p>Error: Please enter a valid email address</p>
</div>
 
<!-- Status messages -->
<div role="status" aria-live="polite">
  <p>Your changes have been saved</p>
</div>
 
<!-- Log of messages -->
<div role="log" aria-live="polite">
  <p>User joined the chat</p>
  <p>New message from John</p>
</div>
// React: Managing live regions
import { useState, useEffect, useRef } from 'react';
 
function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [announcment, setAnnouncement] = useState('');
  const statusRef = useRef<HTMLDivElement>(null);
  
  useEffect(() => {
    const searchTimeout = setTimeout(async () => {
      if (query) {
        const data = await searchAPI(query);
        setResults(data);
        setAnnouncement(`Found ${data.length} results for "${query}"`);
      }
    }, 300);
    
    return () => clearTimeout(searchTimeout);
  }, [query]);
  
  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        aria-label="Search"
        aria-describedby="search-hint"
      />
      <p id="search-hint">Type to search across all articles</p>
      
      <div ref={statusRef} role="status" aria-live="polite" aria-atomic="true">
        {announcment}
      </div>
      
      <ul role="list" aria-label="Search results">
        {results.map((result) => (
          <li key={result.id}>
            <a href={result.url}>{result.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

Keyboard Navigation

Focus Management

/* Never remove focus outlines without providing an alternative */
/* Bad */
*:focus {
  outline: none;
}
 
/* Good: Custom focus styles */
*:focus-visible {
  outline: 2px solid #005fcc;
  outline-offset: 2px;
}
 
/* For mouse users, only show focus for keyboard navigation */
*:focus:not(:focus-visible) {
  outline: none;
}
 
/* High contrast mode support */
@media (forced-colors: active) {
  *:focus-visible {
    outline: 2px solid ButtonText;
  }
}
// Focus trap for modals
function useFocusTrap(isActive: boolean) {
  const containerRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);
  
  useEffect(() => {
    if (!isActive) return;
    
    // Store previously focused element
    previousFocusRef.current = document.activeElement as HTMLElement;
    
    const container = containerRef.current;
    if (!container) return;
    
    // Get all focusable elements
    const focusableElements = container.querySelectorAll<HTMLElement>(
      'a[href], button:not([disabled]), textarea:not([disabled]), ' +
      'input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
    );
    
    const firstFocusable = focusableElements[0];
    const lastFocusable = focusableElements[focusableElements.length - 1];
    
    // Focus first element
    firstFocusable?.focus();
    
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;
      
      if (e.shiftKey) {
        // Shift+Tab: Focus last element if at first
        if (document.activeElement === firstFocusable) {
          e.preventDefault();
          lastFocusable?.focus();
        }
      } else {
        // Tab: Focus first element if at last
        if (document.activeElement === lastFocusable) {
          e.preventDefault();
          firstFocusable?.focus();
        }
      }
    };
    
    container.addEventListener('keydown', handleKeyDown);
    
    return () => {
      container.removeEventListener('keydown', handleKeyDown);
      // Restore focus to previous element
      previousFocusRef.current?.focus();
    };
  }, [isActive]);
  
  return containerRef;
}

Keyboard Interaction Patterns

// Roving tabindex for composite widgets
function KeyboardNavList({ items }: { items: string[] }) {
  const [activeIndex, setActiveIndex] = useState(0);
  const listRef = useRef<HTMLUListElement>(null);
  
  const handleKeyDown = (e: React.KeyboardEvent) => {
    let newIndex = activeIndex;
    
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        newIndex = Math.min(activeIndex + 1, items.length - 1);
        break;
      case 'ArrowUp':
        e.preventDefault();
        newIndex = Math.max(activeIndex - 1, 0);
        break;
      case 'Home':
        e.preventDefault();
        newIndex = 0;
        break;
      case 'End':
        e.preventDefault();
        newIndex = items.length - 1;
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        handleSelect(items[activeIndex]);
        return;
    }
    
    if (newIndex !== activeIndex) {
      setActiveIndex(newIndex);
      // Focus the new active item
      const items = listRef.current?.querySelectorAll('[role="option"]');
      items?.[newIndex]?.focus();
    }
  };
  
  return (
    <ul
      ref={listRef}
      role="listbox"
      aria-label="Select an option"
      onKeyDown={handleKeyDown}
    >
      {items.map((item, index) => (
        <li
          key={item}
          role="option"
          tabIndex={index === activeIndex ? 0 : -1}
          aria-selected={index === activeIndex}
          onClick={() => handleSelect(item)}
        >
          {item}
        </li>
      ))}
    </ul>
  );
}

Keyboard navigation

Accessible Forms

// Accessible form with validation
function RegistrationForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    password: '',
  });
  const [errors, setErrors] = useState<Record<string, string>>({});
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    // Validate
    const newErrors: Record<string, string> = {};
    if (!formData.name) newErrors.name = 'Name is required';
    if (!formData.email) newErrors.email = 'Email is required';
    if (!formData.password) newErrors.password = 'Password is required';
    
    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      // Focus first error field
      const firstErrorField = document.getElementById(Object.keys(newErrors)[0]);
      firstErrorField?.focus();
      return;
    }
    
    await submitForm(formData);
  };
  
  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="name">
          Name <span aria-hidden="true">*</span>
        </label>
        <input
          id="name"
          type="text"
          value={formData.name}
          onChange={(e) => setFormData({ ...formData, name: e.target.value })}
          aria-required="true"
          aria-invalid={!!errors.name}
          aria-describedby={errors.name ? 'name-error' : undefined}
        />
        {errors.name && (
          <p id="name-error" role="alert" className="error">
            {errors.name}
          </p>
        )}
      </div>
      
      <div>
        <label htmlFor="email">
          Email <span aria-hidden="true">*</span>
        </label>
        <input
          id="email"
          type="email"
          value={formData.email}
          onChange={(e) => setFormData({ ...formData, email: e.target.value })}
          aria-required="true"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? 'email-error' : 'email-hint'}
        />
        <p id="email-hint" className="hint">
          We'll never share your email with anyone
        </p>
        {errors.email && (
          <p id="email-error" role="alert" className="error">
            {errors.email}
          </p>
        )}
      </div>
      
      <div>
        <label htmlFor="password">
          Password <span aria-hidden="true">*</span>
        </label>
        <input
          id="password"
          type="password"
          value={formData.password}
          onChange={(e) => setFormData({ ...formData, password: e.target.value })}
          aria-required="true"
          aria-invalid={!!errors.password}
          aria-describedby={errors.password ? 'password-error' : 'password-hint'}
        />
        <p id="password-hint" className="hint">
          Must be at least 8 characters with one number and one special character
        </p>
        {errors.password && (
          <p id="password-error" role="alert" className="error">
            {errors.password}
          </p>
        )}
      </div>
      
      <button type="submit">Create Account</button>
    </form>
  );
}

Testing Accessibility

Automated Testing Tools

// Jest/Vitest: Using jest-axe for automated a11y testing
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
 
expect.extend(toHaveNoViolations);
 
describe('Button accessibility', () => {
  it('has no accessibility violations', async () => {
    const { container } = render(<button>Click me</button>);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
  
  it('has accessible label when using icon only', async () => {
    const { container } = render(
      <button aria-label="Close dialog">
        <CloseIcon />
      </button>
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

Manual Testing Checklist

const accessibilityChecklist = {
  keyboard: [
    'All interactive elements are focusable',
    'Focus order is logical',
    'Focus is visible',
    'No keyboard traps',
    'Custom keyboard shortcuts work',
    'Skip links are available',
  ],
  screenReader: [
    'All content is announced',
    'Images have alt text',
    'Form inputs have labels',
    'Headings are properly nested',
    'ARIA labels are meaningful',
    'Dynamic content is announced',
  ],
  visual: [
    'Color contrast meets WCAG standards',
    'Text is resizable to 200%',
    'Content reflows at different widths',
    'No information conveyed by color alone',
    'Animations can be paused',
  ],
};

Best Practices for Production

  1. Start with semantic HTML — Use native elements before adding ARIA
  2. Test with screen readers — Use NVDA, VoiceOver, or JAWS regularly
  3. Implement keyboard navigation — Ensure all functionality is keyboard accessible
  4. Provide text alternatives — Alt text for images, captions for videos
  5. Use proper color contrast — Minimum 4.5:1 for normal text, 3:1 for large text
  6. Make forms accessible — Labels, error messages, and clear instructions
  7. Manage focus — Logical focus order and visible focus indicators
  8. Test with real users — Include people with disabilities in user testing

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 accessible web interfaces is both an ethical imperative and a legal requirement. By following WCAG guidelines, using semantic HTML, implementing proper ARIA attributes, and testing with assistive technologies, you can create web experiences that work for everyone. Accessibility is not an afterthought or a feature — it's a fundamental quality of good web development. Start with semantic HTML, add ARIA only when necessary, test thoroughly with keyboard and screen readers, and iterate based on real user feedback.