Build Accessible Dropdown Navigation Menus with Pure CSS and ARIA Attributes

Accessible dropdown menus require both a visual implementation and a semantic HTML structure that communicates the menu state to screen readers and keyboard users. The parent menu item must be a <button> (not an <a> tag) when its only function is to toggle a submenu, carry an aria-expanded="false" attribute that switches to "true" when the dropdown is open, and be linked to the dropdown via aria-controls pointing to the submenu’s id. The submenu itself is a <ul> with role="menu" and each item is an <li role="menuitem"> — this semantic structure allows screen readers to announce the number of items and navigate with arrow keys when JavaScript handles the key bindings. CSS handles the show/hide by toggling a class or targeting aria-expanded directly: button[aria-expanded="false"] + ul { visibility: hidden; opacity: 0; } and button[aria-expanded="true"] + ul { visibility: visible; opacity: 1; } — using visibility instead of display: none allows CSS transitions to animate the open/close smoothly while still removing the element from the accessibility tree when hidden. Keyboard navigation requires: Enter/Space to open/close the dropdown, Escape to close and return focus to the trigger button, Arrow Down to enter the submenu and move to the first item, Arrow Up/Arrow Down to navigate items, and Tab to exit the submenu entirely. The :focus-visible pseudo-class is used for focus indicators instead of :focus to show the focus ring only for keyboard navigation without affecting mouse click styling. Click-outside-to-close behavior requires a document click listener that checks whether the click target is inside the menu — using element.contains(event.target) rather than comparing IDs. For purely visual hover dropdowns (desktop navigation where keyboard support is provided separately), @media (hover: hover) gates the hover styles so they only apply on pointer devices. The container queries post shows how the navigation component can adapt its layout to its containing block width rather than the viewport — combining both techniques produces a navigation system that responds correctly in sidebars, headers, and modals.

Problem: CSS dropdown menus built with :hover and display: none are inaccessible to keyboard and screen-reader users because they cannot be opened without a mouse, carry no ARIA state information, and trap focus inside a hidden element.

Solution: Mark the toggle as a <button> with aria-expanded and aria-controls, show/hide the submenu via visibility + opacity driven by an aria-expanded CSS selector, and handle Escape, arrow-key, and click-outside events in a small JavaScript module.

<nav class="site-nav" aria-label="Main navigation">
  <ul class="site-nav__list" role="list">
    <li><a href="/">Home</a></li>

    <li class="site-nav__item--has-dropdown">
      <button class="site-nav__toggle"
              aria-expanded="false"
              aria-controls="services-menu">
        Services
        <svg aria-hidden="true" focusable="false" class="icon-chevron">
          <use href="#icon-chevron-down"></use>
        </svg>
      </button>

      <ul id="services-menu" role="menu" class="site-nav__dropdown">
        <li role="none"><a href="/services/web" role="menuitem">Web Development</a></li>
        <li role="none"><a href="/services/seo"  role="menuitem">SEO</a></li>
        <li role="none"><a href="/services/security" role="menuitem">Security</a></li>
      </ul>
    </li>

    <li><a href="/contact">Contact</a></li>
  </ul>
</nav>

.site-nav__dropdown {
    position: absolute;
    top: 100%;
    left: 0;
    min-width: 200px;
    background: #fff;
    box-shadow: 0 4px 12px rgba(0,0,0,0.12);
    list-style: none;
    padding: 0.5rem 0;
    margin: 0;
    /* Hidden state */
    visibility: hidden;
    opacity: 0;
    transform: translateY(-8px);
    transition: visibility 0s linear 0.15s,
                opacity 0.15s ease,
                transform 0.15s ease;
}

/* Open state — triggered via JS by toggling aria-expanded */
.site-nav__toggle[aria-expanded="true"] + .site-nav__dropdown {
    visibility: visible;
    opacity: 1;
    transform: translateY(0);
    transition-delay: 0s;
}

/* Focus indicator for keyboard users only */
.site-nav__toggle:focus-visible,
.site-nav__dropdown a:focus-visible {
    outline: 2px solid #005fcc;
    outline-offset: 2px;
}

/* Hover dropdowns only on pointer devices */
@media (hover: hover) {
    .site-nav__item--has-dropdown:hover .site-nav__dropdown {
        visibility: visible;
        opacity: 1;
        transform: translateY(0);
        transition-delay: 0s;
    }
}

// Accessible dropdown: keyboard and click-outside handling
(function () {
    'use strict';

    document.querySelectorAll('.site-nav__toggle').forEach(function (btn) {
        const dropdown = document.getElementById(btn.getAttribute('aria-controls'));
        const items    = dropdown ? dropdown.querySelectorAll('[role="menuitem"]') : [];

        function open() {
            btn.setAttribute('aria-expanded', 'true');
            if (items.length) items[0].focus();
        }

        function close(returnFocus = true) {
            btn.setAttribute('aria-expanded', 'false');
            if (returnFocus) btn.focus();
        }

        btn.addEventListener('click', function () {
            btn.getAttribute('aria-expanded') === 'true' ? close() : open();
        });

        btn.addEventListener('keydown', function (e) {
            if (e.key === 'ArrowDown') { e.preventDefault(); open(); }
        });

        // Arrow key navigation inside dropdown
        Array.from(items).forEach(function (item, idx) {
            item.addEventListener('keydown', function (e) {
                if (e.key === 'Escape')    { close(); }
                if (e.key === 'ArrowDown') { e.preventDefault(); (items[idx + 1] || items[0]).focus(); }
                if (e.key === 'ArrowUp')   { e.preventDefault(); (items[idx - 1] || items[items.length - 1]).focus(); }
            });
        });

        // Click outside to close
        document.addEventListener('click', function (e) {
            if (!btn.contains(e.target) && !dropdown.contains(e.target)) {
                close(false);
            }
        });
    });
}());

NOTE: Do not use display: none to hide the dropdown — it removes the element from the accessibility tree immediately, preventing focus from being set on child items. Use visibility: hidden with transition-delay on the hide transition so the element remains in the DOM long enough for the focus transfer to complete before it disappears visually.