Frontend

Web Accessibility: ARIA and Semantic HTML

A comprehensive guide to web accessibility covering ARIA roles, semantic HTML, keyboard navigation, screen reader support, WCAG compliance, and automated testing.

Web Accessibility: ARIA and Semantic HTML

Overview

Web accessibility is not optional. It is a fundamental requirement of professional software engineering. If your application cannot be used by someone navigating with a keyboard, relying on a screen reader, or dealing with low vision, you have shipped a broken product.

I have spent years retrofitting accessibility into applications that were built without it, and I can tell you from experience: it is ten times harder to fix accessibility after the fact than to build it in from the start. The good news is that most accessibility wins come from using HTML correctly. You do not need a library. You do not need a framework plugin. You need to understand semantic HTML and know when to reach for ARIA.

This article covers the full landscape of web accessibility: WCAG compliance levels, semantic HTML elements, ARIA roles and attributes, keyboard navigation, focus management, accessible forms, testing strategies, and a complete working example of an accessible dropdown navigation menu.

Prerequisites

  • Solid understanding of HTML and CSS
  • Basic JavaScript and DOM manipulation
  • Familiarity with browser developer tools
  • A screen reader installed for testing (VoiceOver on Mac, NVDA on Windows)

Why Accessibility Matters

There are three reasons to care about accessibility, and they are all compelling.

Legal obligation. The ADA, Section 508, and the European Accessibility Act all impose requirements on digital products. Lawsuits over inaccessible websites have increased year over year. Domino's Pizza, Winn-Dixie, and countless other companies have lost court cases because their websites could not be used by people with disabilities. This is not theoretical risk.

Moral responsibility. Roughly 15% of the global population lives with some form of disability. When you build an inaccessible website, you are excluding real people from accessing information, completing transactions, or doing their jobs. That is not acceptable.

Business value. Accessible websites perform better in search engines because search crawlers interpret semantic HTML the same way screen readers do. Accessible sites have better usability for everyone, including users on mobile devices, users with temporary injuries, and users in challenging environments like bright sunlight. Accessibility improvements correlate with higher conversion rates and lower bounce rates.

WCAG 2.1 Compliance Levels

The Web Content Accessibility Guidelines (WCAG) 2.1 define three levels of conformance:

Level A is the minimum. It covers the absolute basics: all images have alt text, all form controls have labels, content does not rely solely on color to convey meaning, and no content causes seizures. If you fail Level A, your site is fundamentally broken for assistive technology users.

Level AA is the standard most organizations target and most laws reference. It adds requirements for color contrast (4.5:1 for normal text, 3:1 for large text), text resizing up to 200% without loss of functionality, keyboard accessibility for all interactive elements, and consistent navigation patterns.

Level AAA is the highest level. It requires enhanced contrast ratios (7:1), sign language interpretation for multimedia, and other demanding criteria. Full AAA compliance across an entire site is rarely achievable, but you should meet AAA where practical.

For most projects, target Level AA. It is the legally defensible standard and covers the vast majority of user needs.

Semantic HTML: The Foundation

The single most impactful thing you can do for accessibility is use the right HTML elements. Semantic elements carry implicit meaning and behavior that assistive technologies understand natively. A <button> is focusable, activatable with Enter and Space, and announced as a button by screen readers. A <div> with a click handler is none of those things.

Document Landmarks

Landmark elements create a navigable structure that screen reader users rely on to jump between sections of a page:

<header>
  <nav aria-label="Main navigation">
    <ul>
      <li><a href="/">Home</a></li>
      <li><a href="/articles">Articles</a></li>
      <li><a href="/about">About</a></li>
    </ul>
  </nav>
</header>

<main>
  <article>
    <h1>Article Title</h1>
    <section aria-labelledby="intro-heading">
      <h2 id="intro-heading">Introduction</h2>
      <p>Article content goes here.</p>
    </section>
    <aside aria-label="Related resources">
      <h2>Related Articles</h2>
      <ul>
        <li><a href="/articles/related-topic">Related Topic</a></li>
      </ul>
    </aside>
  </article>
</main>

<footer>
  <p>&copy; 2026 Company Name</p>
</footer>

Each of these elements maps to an ARIA landmark role automatically:

HTML Element Implicit ARIA Role
<header> banner (when top-level)
<nav> navigation
<main> main
<aside> complementary
<footer> contentinfo (when top-level)
<section> region (when labeled)
<article> article

Do not add role="navigation" to a <nav> element. It already has that role. Redundant ARIA is noise.

Heading Hierarchy

Headings are the primary way screen reader users scan and navigate a page. A well-structured heading hierarchy is critical:

<h1>Page Title</h1>
  <h2>Major Section</h2>
    <h3>Subsection</h3>
    <h3>Another Subsection</h3>
  <h2>Another Major Section</h2>
    <h3>Subsection</h3>

Rules for headings:

  • Every page gets exactly one <h1>
  • Never skip levels (do not jump from <h2> to <h4>)
  • Do not choose heading levels based on font size — use CSS for styling
  • Screen reader users can pull up a list of all headings on the page and navigate by heading level

ARIA: When and How to Use It

ARIA (Accessible Rich Internet Applications) extends HTML with attributes that communicate state, properties, and roles to assistive technologies. The first rule of ARIA is critical:

Do not use ARIA if native HTML can do the job.

A <button> is better than <div role="button"> in every way. The native element handles focus, keyboard interaction, and screen reader announcement without any additional code. ARIA exists for situations where HTML falls short, particularly for dynamic web applications with custom widgets.

aria-label vs aria-labelledby vs aria-describedby

These three attributes serve different purposes:

aria-label provides a text label directly on an element when no visible label exists:

<button aria-label="Close dialog">
  <span class="icon-x"></span>
</button>

aria-labelledby references the ID of another element that serves as the label. It overrides any other label:

<h2 id="billing-heading">Billing Address</h2>
<form aria-labelledby="billing-heading">
  <!-- form fields -->
</form>

aria-describedby references supplemental information. It is announced after the label and role:

<input type="password" id="password"
       aria-describedby="password-requirements">
<p id="password-requirements">
  Must be at least 8 characters with one number.
</p>

A screen reader encountering that input would announce: "Password, edit text. Must be at least 8 characters with one number."

aria-live Regions for Dynamic Content

When content on the page updates dynamically — a notification appears, a search result count changes, a form error shows up — screen readers will not announce it unless you tell them to. That is what aria-live regions do.

<div aria-live="polite" aria-atomic="true" id="status-message">
</div>
function showNotification(message) {
  var statusEl = document.getElementById('status-message');
  statusEl.textContent = message;
}

When you update the text content of an aria-live region, the screen reader announces the change. Use polite for non-urgent updates (the announcement waits until the user is idle) and assertive for critical messages that should interrupt.

The aria-atomic="true" attribute tells the screen reader to announce the entire region content, not just the part that changed.

aria-expanded and aria-controls for Interactive Widgets

For custom interactive components like dropdowns, accordions, and collapsible panels, you need to communicate state:

<button aria-expanded="false" aria-controls="submenu-products">
  Products
</button>
<ul id="submenu-products" hidden>
  <li><a href="/product-a">Product A</a></li>
  <li><a href="/product-b">Product B</a></li>
</ul>
function toggleSubmenu(button) {
  var expanded = button.getAttribute('aria-expanded') === 'true';
  var menuId = button.getAttribute('aria-controls');
  var menu = document.getElementById(menuId);

  button.setAttribute('aria-expanded', String(!expanded));
  if (expanded) {
    menu.setAttribute('hidden', '');
  } else {
    menu.removeAttribute('hidden');
  }
}

When aria-expanded is false, a screen reader announces "Products, collapsed, button." When true, it announces "Products, expanded, button." This gives the user clear information about the current state.

Keyboard Navigation

Every interactive element on your page must be operable with a keyboard alone. This is a hard requirement for WCAG Level A.

tabindex

  • tabindex="0" places an element in the natural tab order. Use this on custom interactive elements that are not natively focusable.
  • tabindex="-1" removes an element from the tab order but allows it to be focused programmatically with JavaScript. Useful for managing focus in modals and single-page applications.
  • Never use positive tabindex values. They create a confusing tab order that is nearly impossible to maintain.

Focus Management

When you open a modal dialog, focus must move into the modal. When the modal closes, focus must return to the element that triggered it. This is focus management, and getting it wrong renders your modals unusable for keyboard users.

function openModal(triggerElement) {
  var modal = document.getElementById('modal');
  modal.removeAttribute('hidden');
  modal.setAttribute('aria-modal', 'true');

  var firstFocusable = modal.querySelector(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  if (firstFocusable) {
    firstFocusable.focus();
  }

  modal._triggerElement = triggerElement;
}

function closeModal() {
  var modal = document.getElementById('modal');
  var trigger = modal._triggerElement;

  modal.setAttribute('hidden', '');
  modal.removeAttribute('aria-modal');

  if (trigger) {
    trigger.focus();
  }
}

Focus Trapping in Modals

When a modal is open, Tab and Shift+Tab must cycle through only the focusable elements inside the modal. Focus should not escape to the page behind it.

function trapFocus(modal) {
  var focusableElements = modal.querySelectorAll(
    'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
  );
  var firstElement = focusableElements[0];
  var lastElement = focusableElements[focusableElements.length - 1];

  modal.addEventListener('keydown', function(e) {
    if (e.key === 'Tab') {
      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement.focus();
        }
      } else {
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement.focus();
        }
      }
    }
    if (e.key === 'Escape') {
      closeModal();
    }
  });
}

Skip Navigation Links

Screen reader and keyboard users should not have to tab through your entire navigation on every page load. Provide a skip link:

<body>
  <a href="#main-content" class="skip-link">Skip to main content</a>
  <header>
    <!-- navigation -->
  </header>
  <main id="main-content" tabindex="-1">
    <!-- page content -->
  </main>
</body>
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  padding: 8px 16px;
  background: #000;
  color: #fff;
  z-index: 100;
  transition: top 0.2s;
}

.skip-link:focus {
  top: 0;
}

The skip link is visually hidden until it receives focus via Tab, at which point it appears at the top of the page.

Accessible Forms

Forms are where accessibility most commonly breaks down. Every form control needs a label, every error needs to be communicated, and the overall structure needs to be logical.

Labels and Inputs

<label for="email">Email Address</label>
<input type="email" id="email" name="email"
       required
       aria-required="true"
       aria-describedby="email-help email-error">
<p id="email-help">We will never share your email.</p>
<p id="email-error" role="alert" hidden>
  Please enter a valid email address.
</p>

Never use placeholder as a substitute for a <label>. Placeholders disappear when the user starts typing and have insufficient contrast in most browsers.

Fieldset and Legend for Grouped Controls

<fieldset>
  <legend>Notification Preferences</legend>
  <label>
    <input type="checkbox" name="notify" value="email"> Email
  </label>
  <label>
    <input type="checkbox" name="notify" value="sms"> SMS
  </label>
  <label>
    <input type="checkbox" name="notify" value="push"> Push Notification
  </label>
</fieldset>

Screen readers announce the legend before each input in the group, so users understand the context.

Error Handling

When a form submission fails validation, communicate errors clearly:

function showFieldError(inputId, message) {
  var input = document.getElementById(inputId);
  var errorEl = document.getElementById(inputId + '-error');

  input.setAttribute('aria-invalid', 'true');
  errorEl.textContent = message;
  errorEl.removeAttribute('hidden');
  input.focus();
}

function clearFieldError(inputId) {
  var input = document.getElementById(inputId);
  var errorEl = document.getElementById(inputId + '-error');

  input.removeAttribute('aria-invalid');
  errorEl.textContent = '';
  errorEl.setAttribute('hidden', '');
}

Setting aria-invalid="true" tells the screen reader that this field has an error. Combined with aria-describedby pointing to the error message, the user gets full context.

Accessible Images

Every <img> needs an alt attribute. The question is what to put in it.

  • Informative images: Describe the content. alt="Bar chart showing revenue growth from $2M to $5M over 2024"
  • Decorative images: Use an empty alt. alt="" — the screen reader skips it entirely.
  • Functional images (buttons/links): Describe the action. alt="Search" on a magnifying glass icon inside a button.
  • Complex images: Use alt for a brief description and aria-describedby pointing to a longer description in the page content.

Never use alt="image" or alt="photo.jpg". Those are worse than no alt text at all.

Color Contrast

WCAG AA requires:

  • 4.5:1 contrast ratio for normal text (under 18pt or 14pt bold)
  • 3:1 contrast ratio for large text (18pt+ or 14pt+ bold)
  • 3:1 contrast ratio for UI components and graphical objects

Do not rely on color alone to communicate information. If you mark required fields with a red asterisk, also include the text "(required)" or use aria-required="true".

Use the browser DevTools color picker or the WebAIM Contrast Checker to verify your color combinations.

Accessible Tables

Data tables need proper markup so screen readers can associate cells with their headers:

<table>
  <caption>Quarterly Revenue by Region</caption>
  <thead>
    <tr>
      <th scope="col">Region</th>
      <th scope="col">Q1</th>
      <th scope="col">Q2</th>
      <th scope="col">Q3</th>
      <th scope="col">Q4</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">North America</th>
      <td>$1.2M</td>
      <td>$1.4M</td>
      <td>$1.6M</td>
      <td>$1.8M</td>
    </tr>
    <tr>
      <th scope="row">Europe</th>
      <td>$0.8M</td>
      <td>$0.9M</td>
      <td>$1.1M</td>
      <td>$1.2M</td>
    </tr>
  </tbody>
</table>

The <caption> gives the table a name. The scope attribute tells screen readers whether a header applies to a column or a row.

Testing with Screen Readers

Automated tools catch roughly 30-40% of accessibility issues. The rest require manual testing. You need to use a screen reader.

VoiceOver (macOS/iOS): Built into every Mac. Press Cmd+F5 to toggle it on. Use the rotor (VO+U) to navigate by headings, landmarks, links, and form controls.

NVDA (Windows): Free and open source. Download from nvaccess.org. Use Insert+F7 for the elements list. Practice with it regularly.

Testing checklist:

  1. Navigate the entire page using only the Tab key
  2. Activate all interactive elements with Enter and Space
  3. Open and close all modals, dropdowns, and menus with keyboard
  4. Listen to the screen reader announce every interactive element
  5. Verify that dynamic content changes are announced
  6. Check that focus moves logically and returns correctly after dialogs close

Automated Testing Tools

Use automated tools as a first pass, not a final verdict.

axe-core is the industry standard accessibility testing engine. Integrate it into your test suite:

var axe = require('axe-core');

function runAccessibilityAudit(callback) {
  axe.run(document, {
    rules: {
      'color-contrast': { enabled: true },
      'label': { enabled: true },
      'image-alt': { enabled: true }
    }
  }, function(err, results) {
    if (err) {
      callback(err);
      return;
    }
    if (results.violations.length > 0) {
      results.violations.forEach(function(violation) {
        console.error(
          violation.id + ': ' + violation.description
        );
        violation.nodes.forEach(function(node) {
          console.error('  Element: ' + node.html);
          console.error('  Fix: ' + node.failureSummary);
        });
      });
    }
    callback(null, results);
  });
}

Lighthouse in Chrome DevTools includes an accessibility audit. Run it from the Lighthouse tab and aim for a score above 90.

eslint-plugin-jsx-a11y catches accessibility issues in JSX at build time if you use React.

Accessible Modal Dialog

Here is a properly accessible modal:

<div id="confirm-dialog" role="dialog" aria-labelledby="dialog-title"
     aria-describedby="dialog-desc" aria-modal="true" hidden>
  <div class="dialog-overlay"></div>
  <div class="dialog-content">
    <h2 id="dialog-title">Confirm Deletion</h2>
    <p id="dialog-desc">
      Are you sure you want to delete this item? This action cannot be undone.
    </p>
    <div class="dialog-actions">
      <button id="dialog-cancel">Cancel</button>
      <button id="dialog-confirm">Delete</button>
    </div>
  </div>
</div>

The role="dialog", aria-modal="true", aria-labelledby, and aria-describedby attributes give screen readers full context. Combined with focus trapping and Escape key handling from the earlier examples, this creates a fully accessible modal.

Complete Working Example: Accessible Dropdown Navigation

Here is a fully accessible navigation menu with dropdown submenus, keyboard navigation, ARIA attributes, and screen reader support, tested against WCAG 2.1 AA:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Accessible Navigation Example</title>
  <style>
    *, *::before, *::after { box-sizing: border-box; }

    .skip-link {
      position: absolute;
      top: -50px;
      left: 0;
      padding: 8px 16px;
      background: #1a1a2e;
      color: #fff;
      z-index: 200;
      font-size: 14px;
      text-decoration: none;
      transition: top 0.2s;
    }
    .skip-link:focus { top: 0; }

    .main-nav {
      background: #1a1a2e;
      padding: 0 20px;
    }
    .nav-list {
      list-style: none;
      margin: 0;
      padding: 0;
      display: flex;
    }
    .nav-item {
      position: relative;
    }
    .nav-link, .nav-button {
      display: block;
      padding: 16px 20px;
      color: #e0e0e0;
      text-decoration: none;
      background: none;
      border: none;
      font-size: 16px;
      cursor: pointer;
      font-family: inherit;
    }
    .nav-link:hover, .nav-button:hover,
    .nav-link:focus, .nav-button:focus {
      background: #16213e;
      color: #fff;
      outline: 2px solid #4fc3f7;
      outline-offset: -2px;
    }

    .subnav {
      position: absolute;
      top: 100%;
      left: 0;
      background: #16213e;
      list-style: none;
      margin: 0;
      padding: 4px 0;
      min-width: 220px;
      box-shadow: 0 4px 12px rgba(0,0,0,0.3);
      z-index: 100;
    }
    .subnav[hidden] { display: none; }
    .subnav a {
      display: block;
      padding: 10px 20px;
      color: #e0e0e0;
      text-decoration: none;
    }
    .subnav a:hover, .subnav a:focus {
      background: #0f3460;
      color: #fff;
      outline: 2px solid #4fc3f7;
      outline-offset: -2px;
    }

    .main-content {
      max-width: 800px;
      margin: 40px auto;
      padding: 0 20px;
      font-family: sans-serif;
      line-height: 1.6;
    }

    /* Live region is visually hidden but available to screen readers */
    .sr-only {
      position: absolute;
      width: 1px;
      height: 1px;
      padding: 0;
      margin: -1px;
      overflow: hidden;
      clip: rect(0, 0, 0, 0);
      white-space: nowrap;
      border: 0;
    }
  </style>
</head>
<body>
  <a href="#main-content" class="skip-link">Skip to main content</a>

  <div aria-live="polite" aria-atomic="true" class="sr-only"
       id="nav-announcements"></div>

  <header>
    <nav class="main-nav" aria-label="Main navigation">
      <ul class="nav-list" role="menubar">
        <li class="nav-item" role="none">
          <a href="/" class="nav-link" role="menuitem">Home</a>
        </li>
        <li class="nav-item" role="none">
          <button class="nav-button" role="menuitem"
                  aria-haspopup="true"
                  aria-expanded="false"
                  aria-controls="submenu-products">
            Products
          </button>
          <ul id="submenu-products" class="subnav"
              role="menu" aria-label="Products submenu" hidden>
            <li role="none">
              <a href="/products/api-tools" role="menuitem">API Tools</a>
            </li>
            <li role="none">
              <a href="/products/monitoring" role="menuitem">Monitoring</a>
            </li>
            <li role="none">
              <a href="/products/analytics" role="menuitem">Analytics</a>
            </li>
          </ul>
        </li>
        <li class="nav-item" role="none">
          <button class="nav-button" role="menuitem"
                  aria-haspopup="true"
                  aria-expanded="false"
                  aria-controls="submenu-resources">
            Resources
          </button>
          <ul id="submenu-resources" class="subnav"
              role="menu" aria-label="Resources submenu" hidden>
            <li role="none">
              <a href="/resources/docs" role="menuitem">Documentation</a>
            </li>
            <li role="none">
              <a href="/resources/tutorials" role="menuitem">Tutorials</a>
            </li>
            <li role="none">
              <a href="/resources/blog" role="menuitem">Blog</a>
            </li>
          </ul>
        </li>
        <li class="nav-item" role="none">
          <a href="/about" class="nav-link" role="menuitem">About</a>
        </li>
        <li class="nav-item" role="none">
          <a href="/contact" class="nav-link" role="menuitem">Contact</a>
        </li>
      </ul>
    </nav>
  </header>

  <main id="main-content" tabindex="-1">
    <div class="main-content">
      <h1>Welcome to the Accessible Navigation Demo</h1>
      <p>This navigation demonstrates WCAG 2.1 AA compliant dropdown menus
         with full keyboard support and screen reader announcements.</p>
    </div>
  </main>

  <script>
    (function() {
      var menubar = document.querySelector('[role="menubar"]');
      var topLevelItems = menubar.querySelectorAll(':scope > li > [role="menuitem"]');
      var announcements = document.getElementById('nav-announcements');

      function announce(message) {
        announcements.textContent = '';
        // Small delay ensures screen readers detect the change
        setTimeout(function() {
          announcements.textContent = message;
        }, 50);
      }

      function openSubmenu(button) {
        var menuId = button.getAttribute('aria-controls');
        var menu = document.getElementById(menuId);
        if (!menu) return;

        button.setAttribute('aria-expanded', 'true');
        menu.removeAttribute('hidden');

        var firstItem = menu.querySelector('[role="menuitem"]');
        if (firstItem) {
          firstItem.focus();
        }
        announce(button.textContent.trim() + ' submenu opened');
      }

      function closeSubmenu(button) {
        var menuId = button.getAttribute('aria-controls');
        var menu = document.getElementById(menuId);
        if (!menu) return;

        button.setAttribute('aria-expanded', 'false');
        menu.setAttribute('hidden', '');
        announce(button.textContent.trim() + ' submenu closed');
      }

      function closeAllSubmenus() {
        var buttons = menubar.querySelectorAll('[aria-expanded="true"]');
        for (var i = 0; i < buttons.length; i++) {
          closeSubmenu(buttons[i]);
        }
      }

      function getTopLevelIndex(element) {
        for (var i = 0; i < topLevelItems.length; i++) {
          if (topLevelItems[i] === element) return i;
        }
        return -1;
      }

      // Handle keyboard navigation on the menubar
      menubar.addEventListener('keydown', function(e) {
        var target = e.target;
        var role = target.getAttribute('role');

        // Top-level menubar items
        if (target.parentElement.parentElement === menubar) {
          var index = getTopLevelIndex(target);

          switch (e.key) {
            case 'ArrowRight':
              e.preventDefault();
              var nextIndex = (index + 1) % topLevelItems.length;
              topLevelItems[nextIndex].focus();
              break;

            case 'ArrowLeft':
              e.preventDefault();
              var prevIndex = (index - 1 + topLevelItems.length) % topLevelItems.length;
              topLevelItems[prevIndex].focus();
              break;

            case 'ArrowDown':
              if (target.getAttribute('aria-haspopup') === 'true') {
                e.preventDefault();
                closeAllSubmenus();
                openSubmenu(target);
              }
              break;

            case 'Enter':
            case ' ':
              if (target.getAttribute('aria-haspopup') === 'true') {
                e.preventDefault();
                var isOpen = target.getAttribute('aria-expanded') === 'true';
                closeAllSubmenus();
                if (!isOpen) {
                  openSubmenu(target);
                }
              }
              break;

            case 'Escape':
              closeAllSubmenus();
              target.focus();
              break;
          }
        }

        // Submenu items
        if (target.closest('.subnav')) {
          var submenu = target.closest('.subnav');
          var items = submenu.querySelectorAll('[role="menuitem"]');
          var currentIndex = -1;

          for (var j = 0; j < items.length; j++) {
            if (items[j] === target) {
              currentIndex = j;
              break;
            }
          }

          var parentButton = submenu.parentElement.querySelector('[aria-controls="' + submenu.id + '"]');

          switch (e.key) {
            case 'ArrowDown':
              e.preventDefault();
              var next = (currentIndex + 1) % items.length;
              items[next].focus();
              break;

            case 'ArrowUp':
              e.preventDefault();
              if (currentIndex === 0) {
                closeSubmenu(parentButton);
                parentButton.focus();
              } else {
                var prev = currentIndex - 1;
                items[prev].focus();
              }
              break;

            case 'Escape':
              e.preventDefault();
              closeSubmenu(parentButton);
              parentButton.focus();
              break;

            case 'Tab':
              closeAllSubmenus();
              break;
          }
        }
      });

      // Handle mouse interactions
      var navItems = menubar.querySelectorAll('.nav-item');
      for (var i = 0; i < navItems.length; i++) {
        (function(navItem) {
          var button = navItem.querySelector('[aria-haspopup="true"]');
          if (!button) return;

          navItem.addEventListener('mouseenter', function() {
            closeAllSubmenus();
            openSubmenu(button);
          });

          navItem.addEventListener('mouseleave', function() {
            closeSubmenu(button);
          });
        })(navItems[i]);
      }

      // Close menus when clicking outside
      document.addEventListener('click', function(e) {
        if (!menubar.contains(e.target)) {
          closeAllSubmenus();
        }
      });

      // Close menus on focus outside
      document.addEventListener('focusin', function(e) {
        if (!menubar.contains(e.target)) {
          closeAllSubmenus();
        }
      });
    })();
  </script>
</body>
</html>

This navigation implementation provides:

  • Arrow key navigation: Left/Right arrows move between top-level items. Down arrow opens submenus. Up arrow navigates within submenus and returns to the parent.
  • Enter and Space toggle submenus on buttons and activate links.
  • Escape closes the current submenu and returns focus to the parent button.
  • Tab moves focus out of the menu naturally, closing any open submenu.
  • Mouse support with hover-to-open and click-outside-to-close.
  • Screen reader announcements via an aria-live region for submenu open/close events.
  • Visible focus indicators with a clear outline that meets contrast requirements.
  • Skip link for bypassing the navigation entirely.

Common Issues and Troubleshooting

Problem: Screen reader announces "clickable" instead of a button role. You used a <div> or <span> with an onclick handler. Replace it with a <button>. If you must use a non-semantic element, add role="button" and tabindex="0", and handle both Enter and Space key events. But just use a <button>.

Problem: Dynamic content updates are not announced. You forgot to use an aria-live region, or you are replacing the entire container element instead of updating its text content. The aria-live attribute must be on the container before the content changes, and the container must remain in the DOM.

Problem: Focus disappears after closing a modal or removing an element. When you remove or hide the element that currently has focus, the browser resets focus to the <body>. Always move focus to a logical destination before or immediately after removing the focused element.

Problem: Custom select dropdown is not accessible. Custom select components are notoriously difficult to make accessible. Use the native <select> element whenever possible. If you must build a custom one, follow the ARIA combobox pattern exactly and test thoroughly with multiple screen readers.

Problem: Focus outline is removed globally with outline: none. Never remove focus outlines without providing a visible alternative. Use :focus-visible to show outlines only for keyboard navigation if you want to hide them on mouse clicks.

Best Practices

  1. Start with semantic HTML. Use <button> for actions, <a> for navigation, <input> for data entry. ARIA is a supplement, not a replacement for correct HTML.

  2. Test with a keyboard first. Before you ever open a screen reader, unplug your mouse and try to use your application. If you cannot complete every task with Tab, Enter, Space, Escape, and arrow keys, you have a problem.

  3. Provide visible focus indicators on every interactive element. The default browser outline is acceptable. A custom focus style is better. No focus indicator at all is a WCAG failure.

  4. Label everything. Every form input needs a <label>. Every icon button needs an aria-label. Every navigation region needs an aria-label when there are multiple <nav> elements on the page.

  5. Do not disable zoom. Never set maximum-scale=1 or user-scalable=no in your viewport meta tag. Users with low vision need to zoom in.

  6. Include accessibility in your definition of done. A feature is not complete until it passes keyboard testing, screen reader testing, and automated accessibility audits. Make this a standard part of code review.

  7. Announce dynamic changes. Any time content changes without a page reload — form errors, notifications, loading states, search results — use aria-live regions or role="alert" to ensure screen reader users are informed.

  8. Design with sufficient contrast from the start. Do not pick colors and check contrast later. Use tools like the WebAIM Contrast Checker during the design phase.

References

Powered by Contentful