Dropdown Menu

A simple, accessible dropdown menu with minimal JavaScript.

How It Works

This dropdown menu is built primarily using HTML and CSS with the :hover and :focus-within pseudo-classes to show/hide dropdown content without JavaScript on desktop devices.

For mobile devices, the component uses a CSS-only approach with hidden checkboxes and labels to handle touch interactions, making it responsive across all devices.

The JavaScript's role: A minimal amount of JavaScript is used solely for accessibility improvements:

  1. ARIA attribute management: Updates aria-expanded states dynamically when dropdowns open/close
  2. Keyboard navigation: Enables arrow key navigation within dropdown menus
  3. Focus management: Restores focus properly when dropdowns are closed

Without this JavaScript, the dropdowns would still function for mouse and touch users, but would lack proper keyboard accessibility and screen reader announcements.

Accessibility Features

ARIA Attributes Explained

  • aria-haspopup="true": Indicates that the button controls a popup menu
  • aria-expanded="false/true": Indicates whether the dropdown menu is currently collapsed or expanded
  • role="menu": Identifies the dropdown as a menu
  • role="menuitem": Identifies each item in the dropdown menu
  • role="menubar": Used for the horizontal navigation bar containing dropdown menus
  • role="separator": Used for divider elements that separate groups of menu items
  • aria-labelledby: Associates the menu with the button that controls it
  • role="none": Removes the implicit role from list items that are only structural

Keyboard Navigation

  • Tab key: Moves focus between dropdown buttons and other focusable elements
  • Enter/Space: Opens the dropdown when a dropdown button has focus
  • Escape: Closes an open dropdown menu
  • Down arrow: Opens a dropdown and moves focus to the first item
  • Up/Down arrows: Navigate between items within an open dropdown
  • Right arrow: Opens a submenu when focus is on a menu item with a submenu

Focus Management

The dropdown enhances focus management through:

  • Using :focus-within to keep dropdowns open when their contents have focus
  • Automatically focusing the first menu item when a dropdown is opened via keyboard
  • Returning focus to the trigger button when a dropdown is closed
  • Maintaining proper focus order within nested dropdown structures

What Not To Add

For best accessibility practices, avoid these common mistakes:

  • tabindex="-1" on menu items: This prevents keyboard users from accessing menu items
  • autofocus attributes: These can be disorienting and should be avoided
  • Using <div> elements for interactive menu items without proper roles and keyboard support
  • Non-semantic toggle mechanisms: Using arbitrary elements as toggles without proper ARIA attributes

Additional Resources

Implementation Code

HTML

<!-- Simple Dropdown Menu -->
<div class="dropdown">
  <button class="dropdown-toggle" id="dropdown-toggle-1" aria-haspopup="true" aria-expanded="false">
    Settings
    <span class="caret">▼</span>
  </button>
  <ul class="dropdown-menu" role="menu" aria-labelledby="dropdown-toggle-1">
    <li>
      <a href="#" class="dropdown-item" role="menuitem">Profile</a>
    </li>
    <li>
      <a href="#" class="dropdown-item" role="menuitem">Preferences</a>
    </li>
    <li>
      <a href="#" class="dropdown-item" role="menuitem">Notifications</a>
    </li>
    <li class="divider" role="separator"></li>
    <li>
      <a href="#" class="dropdown-item" role="menuitem">Logout</a>
    </li>
  </ul>
</div>

<!-- Nested Dropdown Menu -->
<nav class="nav-dropdown nested-dropdown">
  <ul class="nav-menu" role="menubar">
    <li class="nav-item" role="none">
      <a href="#" class="nav-link" role="menuitem">Home</a>
    </li>
    <li class="nav-item has-dropdown" role="none">
      <a class="nav-link" id="products-menu" role="menuitem" aria-haspopup="true" aria-expanded="false">
        Products <span class="caret">▼</span>
      </a>
      <ul class="nav-dropdown-menu" role="menu" aria-labelledby="products-menu">
        <li role="none">
          <a href="#" class="dropdown-item" role="menuitem">Electronics</a>
        </li>
        <li class="has-dropdown" role="none">
          <a class="dropdown-item" id="home-garden-menu" role="menuitem" aria-haspopup="true" aria-expanded="false">
            Home & Garden <span class="caret-right">▶</span>
          </a>
          <ul class="nav-dropdown-submenu" role="menu" aria-labelledby="home-garden-menu">
            <li role="none">
              <a href="#" class="dropdown-item" role="menuitem">Furniture</a>
            </li>
          </ul>
        </li>
      </ul>
    </li>
  </ul>
</nav>

CSS

/* Dropdown toggle button */
.dropdown {
  position: relative;
  display: inline-block;
}

.dropdown-toggle {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  background: var(--white);
  color: var(--primary);
  border: 2px solid var(--primary);
  border-radius: var(--radius);
  padding: 0.75rem 1.25rem;
  cursor: pointer;
}

.caret {
  font-size: 0.75rem;
  transition: transform 0.2s ease;
}

/* Dropdown menu */
.dropdown-menu {
  position: absolute;
  top: 100%;
  left: 0;
  min-width: 180px;
  background-color: var(--white);
  border-radius: var(--radius);
  box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
  padding: 0.5rem 0;
  margin-top: 0.5rem;
  list-style: none;
  z-index: 10;
  opacity: 0;
  visibility: hidden;
  transform: translateY(-10px);
  transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s;
  border: 1px solid var(--primary);
}

/* Show dropdown menu on hover and focus */
.dropdown:hover .dropdown-menu,
.dropdown:focus-within .dropdown-menu {
  display: block;
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}

.dropdown:hover .caret,
.dropdown:focus-within .caret {
  transform: rotate(180deg);
}

/* Mobile-responsive nested dropdowns */
@media (max-width: 768px) {
  .nav-dropdown-menu {
    position: static;
    width: 100%;
    opacity: 0;
    visibility: hidden;
    max-height: 0;
    overflow: hidden;
    transition: opacity 0.3s ease, visibility 0.3s ease, max-height 0.3s ease;
  }

  .has-dropdown:hover > .nav-dropdown-menu,
  .has-dropdown:focus-within > .nav-dropdown-menu {
    opacity: 1;
    visibility: visible;
    max-height: 500px;
    padding: 0.5rem 0;
  }
}

JavaScript (for Accessibility)

// This script enhances keyboard accessibility and ARIA states
document.addEventListener('DOMContentLoaded', function() {
  const dropdownButtons = document.querySelectorAll('[aria-haspopup="true"]');
  
  // Add keyboard navigation and ARIA state management
  dropdownButtons.forEach(button => {
    button.addEventListener('click', function() {
      const expanded = this.getAttribute('aria-expanded') === 'true';
      
      // Toggle expanded state
      this.setAttribute('aria-expanded', !expanded);
      
      // Find the dropdown menu
      const menuId = this.getAttribute('aria-controls') || 
                    this.getAttribute('id');
      const menu = document.querySelector('[aria-labelledby="' + menuId + '"]');
      
      if (menu) {
        // Add keyboard navigation within menu
        const menuItems = menu.querySelectorAll('[role="menuitem"]');
        
        if (expanded) {
          // Close the menu
          this.focus();
        } else {
          // Open the menu and focus first item
          if (menuItems.length > 0) {
            menuItems[0].focus();
          }
        }
      }
    });
    
    // Handle arrow key navigation within dropdowns
    button.addEventListener('keydown', function(e) {
      const menuId = this.getAttribute('aria-controls') || 
                    this.getAttribute('id');
      const menu = document.querySelector('[aria-labelledby="' + menuId + '"]');
      
      if (!menu) return;
      
      // Down arrow - open menu
      if (e.key === 'ArrowDown' || e.key === 'Down') {
        e.preventDefault();
        this.click();
      }
      
      // Escape key - close menu
      if (e.key === 'Escape' || e.key === 'Esc') {
        e.preventDefault();
        this.setAttribute('aria-expanded', 'false');
        this.focus();
      }
    });
  });
});