html|css|javascript 153 views

Accessible Dropdown Menu with ARIA Roles

This snippet creates an accessible dropdown menu using ARIA roles and modern JavaScript for better usability.

By TWC Team • Jan 25, 2026

Code

<!--
  Accessible Dropdown Menu (ARIA + keyboard support).
  Use for navigation menus requiring robust accessibility and UX.
-->

<nav aria-label="Primary">
  <ul class="dropdown">
    <li class="dropdown-item">
      <button
        class="dropdown-toggle"
        type="button"
        aria-haspopup="true"
        aria-expanded="false"
        aria-controls="dropdown-menu-1"
        id="dropdown-button-1"
      >
        Menu
      </button>

      <ul
        class="dropdown-menu"
        id="dropdown-menu-1"
        role="menu"
        aria-labelledby="dropdown-button-1"
      >
        <li role="none"><a role="menuitem" href="#item-1" tabindex="-1">Item 1</a></li>
        <li role="none"><a role="menuitem" href="#item-2" tabindex="-1">Item 2</a></li>
        <li role="none"><a role="menuitem" href="#item-3" tabindex="-1">Item 3</a></li>
      </ul>
    </li>
  </ul>
</nav>

<style>
  .dropdown { list-style: none; margin: 0; padding: 0; }
  .dropdown-item { position: relative; display: inline-block; }

  .dropdown-toggle {
    appearance: none;
    border: 1px solid #d0d7de;
    background: #fff;
    padding: .5rem .75rem;
    border-radius: .5rem;
    cursor: pointer;
  }
  .dropdown-toggle:focus-visible { outline: 3px solid #2f81f7; outline-offset: 2px; }

  .dropdown-menu {
    position: absolute;
    top: calc(100% + .5rem);
    left: 0;
    min-width: 12rem;
    list-style: none;
    margin: 0;
    padding: .25rem;
    border: 1px solid #d0d7de;
    border-radius: .75rem;
    background: #fff;
    box-shadow: 0 10px 30px rgba(0,0,0,.12);
    display: none;
  }
  .dropdown-menu[hidden] { display: none; }

  .dropdown-menu a {
    display: block;
    padding: .5rem .6rem;
    border-radius: .5rem;
    text-decoration: none;
    color: #111;
  }
  .dropdown-menu a:focus, .dropdown-menu a:hover { background: #f3f4f6; }
</style>

<script>
  // Initializes a single dropdown with click, Escape, outside-click, and roving tabindex.
  (() => {
    const root = document.querySelector('.dropdown-item');
    if (!root) return;

    const button = root.querySelector('.dropdown-toggle');
    const menu = root.querySelector('.dropdown-menu');
    const items = Array.from(menu.querySelectorAll('[role="menuitem"]'));

    if (!button || !menu || items.length === 0) return;

    const setOpen = (open) => {
      button.setAttribute('aria-expanded', String(open));
      menu.hidden = !open; // Uses [hidden] to avoid layout thrash from inline styles
      items.forEach((el) => el.tabIndex = -1);
      if (open) items[0].tabIndex = 0;
    };

    const isOpen = () => button.getAttribute('aria-expanded') === 'true';

    const focusItem = (index) => {
      const next = items[(index + items.length) % items.length];
      items.forEach((el) => el.tabIndex = -1);
      next.tabIndex = 0;
      next.focus();
    };

    // Initial state
    menu.hidden = true;

    button.addEventListener('click', () => {
      const open = !isOpen();
      setOpen(open);
      if (open) items[0].focus();
    });

    button.addEventListener('keydown', (e) => {
      if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        if (!isOpen()) setOpen(true);
        items[0].focus();
      }
    });

    menu.addEventListener('keydown', (e) => {
      const currentIndex = items.indexOf(document.activeElement);
      if (e.key === 'Escape') { e.preventDefault(); setOpen(false); button.focus(); }
      if (e.key === 'ArrowDown') { e.preventDefault(); focusItem(currentIndex + 1); }
      if (e.key === 'ArrowUp') { e.preventDefault(); focusItem(currentIndex - 1); }
      if (e.key === 'Home') { e.preventDefault(); focusItem(0); }
      if (e.key === 'End') { e.preventDefault(); focusItem(items.length - 1); }
      if (e.key === 'Tab') setOpen(false); // allow tabbing away while closing menu
    });

    // Close on outside click (capture phase reduces race with focus changes).
    document.addEventListener('pointerdown', (e) => {
      if (!isOpen()) return;
      if (!root.contains(e.target)) setOpen(false);
    }, { capture: true });

    // Close when focus leaves the dropdown entirely.
    root.addEventListener('focusout', () => {
      requestAnimationFrame(() => { // waits for focus to settle
        if (isOpen() && !root.contains(document.activeElement)) setOpen(false);
      });
    });

    // Example usage: duplicate .dropdown-item blocks for multiple menus and run init per root.
  })();
</script>
Back to Snippets