How to create an accessible menu with ARIA and keyboard in JS in WordPress

Contents

Why accessible menus matter

Accessible menus ensure that keyboard users and people using assistive technologies (screen readers, switch devices, etc.) can discover, operate, and understand the sites navigation. ARIA attributes combined with proper keyboard handling and focus management create an experience that is perceivable, operable, and understandable — the core of web accessibility.

Patterns covered in this tutorial

  • Menu button (a button opens a popup menu)
  • Menubar (a horizontal toolbar of menu items with optional submenus)
  • Nested submenus (submenu open/close behavior with keyboard)
  • Roving tabindex and focus management
  • WordPress integration: enqueueing the script and graceful fallback

Quick accessibility principles and ARIA attributes used

Use this as a cheat sheet. Each attribute appears in examples and is explained below.

role Defines ARIA role such as menu, menuitem, menuitemcheckbox, presentation, menubar
aria-haspopup Indicates that an element has a popup. Values: true or menu (prefer menu when available)
aria-expanded Indicates whether a popup or submenu is currently expanded (true or false)
aria-controls Points to the id of the controlled element (the menu DOM node)
aria-hidden Optional: mark hidden submenus when closed (use carefully screen readers may treat differently)
tabindex Use tabindex=0 for focusable item in tab order, -1 for programmatically focusable but skipped by Tab. Roving tabindex uses one 0 and others -1.

Which keyboard behaviors to implement

Follow WAI-ARIA Authoring Practices for menus and menubars. Implement at least the following:

  • Enter/Space activates a menuitem (or opens a submenu when focused on a parent)
  • Escape closes the open menu/submenu and returns focus to the menu button or parent menu item
  • Tab moves focus out of the menu in the natural document order (do not trap focus permanently)
  • Arrow keys — for menus: Up/Down to move among vertical items for menubars: Left/Right to move among top-level items
  • Home/End jump to first/last item

General implementation notes

  1. Start with a semantic list of links for the no-JS fallback. If JavaScript is disabled, the links are visible and navigable.
  2. When JavaScript runs, hide expanded menus from visual display and from keyboard/tab order (use CSS classes and tabindex=-1 for hidden items).
  3. Prefer role=menu and role=menuitem for popup menus. For site navigation use semantically appropriate elements (e.g., a nav element) and consider a menubar only for desktop-style toolbar menus.
  4. Keep ARIA values in sync with UI state: aria-expanded, aria-hidden, tabindex.
  5. Use keydown events for arrow keys and preventDefault on handled keys. Use click for activation.
  6. Avoid changing focus in ways that confuse screen-reader virtual cursor modes. Move focus only when opening a menu or navigating items.

Example A — A simple accessible menu button

This pattern is for a button that toggles a popup menu. Behavior:

  • Button: aria-haspopup=menu, aria-expanded, aria-controls
  • Menu: role=menu and children with role=menuitem
  • Keyboard: Enter/Space to open, ArrowDown to move to first item, Escape to close, Up/Down to navigate

HTML markup (no-JS fallback is visible links):





Minimal CSS to hide/show:

#mainMenu[hidden] {
  display: none
}
#mainMenu {
  position: absolute
  background: white
  border: 1px solid #ddd
  padding: 0.25rem
  margin: 0
  list-style: none
}
#mainMenu a[role=menuitem] {
  display: block
  padding: 0.5rem 1rem
  color: #111
  text-decoration: none
}
#mainMenu a[role=menuitem]:focus {
  outline: 2px solid #005fcc
  background: #e7f0ff
}

JavaScript for the menu button (handles opening/closing, focus, and arrow navigation):

(function () {
  // Utility helpers
  function isPrintableKey(e) {
    // Letters, numbers, punctuation (used rarely for typeahead)
    return e.key  e.key.length === 1
  }

  var button = document.getElementById(mainMenuButton)
  var menu = document.getElementById(mainMenu)
  var menuItems = Array.prototype.slice.call(menu.querySelectorAll([role=menuitem]))

  // Roving tabindex: when menu is closed, items are not tabbable.
  function closeMenu(returnFocus) {
    button.setAttribute(aria-expanded, false)
    menu.setAttribute(aria-hidden, true)
    menu.hidden = true
    // set items to be programmatically focusable only
    menuItems.forEach(function (item) {
      item.setAttribute(tabindex, -1)
    })
    if (returnFocus) {
      button.focus()
    }
  }

  function openMenu() {
    button.setAttribute(aria-expanded, true)
    menu.setAttribute(aria-hidden, false)
    menu.hidden = false
    // make first item focusable and move focus there
    menuItems.forEach(function (item, idx) {
      item.setAttribute(tabindex, idx === 0 ? 0 : -1)
    })
    menuItems[0].focus()
  }

  // Toggle on button click
  button.addEventListener(click, function (e) {
    var expanded = button.getAttribute(aria-expanded) === true
    if (expanded) {
      closeMenu(true)
    } else {
      openMenu()
    }
  })

  // Keyboard on the button
  button.addEventListener(keydown, function (e) {
    if (e.key === ArrowDown  e.key === Down) {
      e.preventDefault()
      openMenu()
    }
    if (e.key === Enter  e.key ===    e.key === Spacebar) {
      e.preventDefault()
      openMenu()
    }
  })

  // Key handling for menu items
  menu.addEventListener(keydown, function (e) {
    var currentIndex = menuItems.indexOf(document.activeElement)
    if (e.key === Escape  e.key === Esc) {
      e.preventDefault()
      closeMenu(true)
      return
    }

    if (e.key === ArrowDown  e.key === Down) {
      e.preventDefault()
      var next = (currentIndex   1) % menuItems.length
      moveFocus(currentIndex, next)
    } else if (e.key === ArrowUp  e.key === Up) {
      e.preventDefault()
      var prev = (currentIndex - 1   menuItems.length) % menuItems.length
      moveFocus(currentIndex, prev)
    } else if (e.key === Home) {
      e.preventDefault()
      moveFocus(currentIndex, 0)
    } else if (e.key === End) {
      e.preventDefault()
      moveFocus(currentIndex, menuItems.length - 1)
    } else if (e.key === Enter  e.key ===    e.key === Spacebar) {
      // Activate link: allow default for anchors if role=menuitem on non-link,
      // you would implement activation here.
    } else if (isPrintableKey(e)) {
      // Optional: type-to-search behavior could be added here
    }
  })

  function moveFocus(fromIdx, toIdx) {
    if (fromIdx >= 0) {
      menuItems[fromIdx].setAttribute(tabindex, -1)
    }
    menuItems[toIdx].setAttribute(tabindex, 0)
    menuItems[toIdx].focus()
  }

  // Close when clicking outside
  document.addEventListener(click, function (e) {
    if (!menu.contains(e.target)  e.target !== button) {
      closeMenu(false)
    }
  })

  // Initialize: hide menu and set tabindexes
  closeMenu(false)
})()

Example B — Menubar with submenus (roving tabindex, Arrow navigation)

This pattern behaves like a desktop application menubar. Top-level items are tabbable arrow keys move focus among them. Submenus open with Down or Right depending on orientation.

HTML markup for a menubar (no-JS fallback is horizontal links):



JavaScript: menubar behavior implementing roving tabindex across top-level items and submenu navigation.

(function () {
  // Selectors
  var menubar = document.getElementById(siteMenubar)
  var topLevelItems = Array.prototype.slice.call(menubar.querySelectorAll([role=menuitem]))

  // Initialize roving tabindex: first item 0, others -1
  topLevelItems.forEach(function (el, idx) {
    el.setAttribute(tabindex, idx === 0 ? 0 : -1)
  })

  // Utility to get submenu by aria-controls
  function getSubmenu(item) {
    var id = item.getAttribute(aria-controls)
    if (!id) return null
    return document.getElementById(id)
  }

  function openSubmenu(item) {
    var submenu = getSubmenu(item)
    if (!submenu) return
    item.setAttribute(aria-expanded, true)
    submenu.hidden = false
    submenu.setAttribute(aria-hidden, false)
    // Set first submenu item tabindex to 0 and focus it
    var submenuItems = submenu.querySelectorAll([role=menuitem])
    if (submenuItems.length) {
      submenuItems.forEach(function (si) { si.setAttribute(tabindex, -1) })
      submenuItems[0].setAttribute(tabindex, 0)
      submenuItems[0].focus()
    }
  }

  function closeSubmenu(item, returnFocusToItem) {
    var submenu = getSubmenu(item)
    if (!submenu) return
    item.setAttribute(aria-expanded, false)
    submenu.hidden = true
    submenu.setAttribute(aria-hidden, true)
    // set submenu items to tabindex -1
    var submenuItems = submenu.querySelectorAll([role=menuitem])
    submenuItems.forEach(function (si) { si.setAttribute(tabindex, -1) })
    if (returnFocusToItem) item.focus()
  }

  // Arrow navigation for top-level menu items
  menubar.addEventListener(keydown, function (e) {
    var target = e.target
    if (target.getAttribute(role) !== menuitem) return

    var currentIndex = topLevelItems.indexOf(target)
    if (e.key === ArrowRight  e.key === Right) {
      e.preventDefault()
      var next = (currentIndex   1) % topLevelItems.length
      moveTopFocus(currentIndex, next)
    } else if (e.key === ArrowLeft  e.key === Left) {
      e.preventDefault()
      var prev = (currentIndex - 1   topLevelItems.length) % topLevelItems.length
      moveTopFocus(currentIndex, prev)
    } else if (e.key === ArrowDown  e.key === Down) {
      // Open submenu if present
      var submenu = getSubmenu(target)
      if (submenu) {
        e.preventDefault()
        openSubmenu(target)
      }
    } else if (e.key === Enter  e.key ===    e.key === Spacebar) {
      // If it has submenu, open it otherwise let link activate
      var submenu2 = getSubmenu(target)
      if (submenu2) {
        e.preventDefault()
        openSubmenu(target)
      }
    }
  })

  function moveTopFocus(fromIdx, toIdx) {
    topLevelItems[fromIdx].setAttribute(tabindex, -1)
    topLevelItems[toIdx].setAttribute(tabindex, 0)
    topLevelItems[toIdx].focus()
  }

  // Handle blur or click-away to close any open submenus
  document.addEventListener(click, function (e) {
    // If clicked outside menubar, close all
    if (!menubar.contains(e.target)) {
      topLevelItems.forEach(function (item) {
        var submenu = getSubmenu(item)
        if (submenu) {
          item.setAttribute(aria-expanded, false)
          submenu.hidden = true
          submenu.setAttribute(aria-hidden, true)
        }
      })
    }
  })

  // Submenu keyboard handling (delegation)
  document.addEventListener(keydown, function (e) {
    var active = document.activeElement
    if (!active) return
    var role = active.getAttribute(role)

    // If focus is inside a submenu
    if (role === menuitem  active.closest([role=menu])) {
      var submenu = active.closest([role=menu])
      var items = Array.prototype.slice.call(submenu.querySelectorAll([role=menuitem]))
      var idx = items.indexOf(active)

      if (e.key === ArrowDown  e.key === Down) {
        e.preventDefault()
        var next = (idx   1) % items.length
        items[idx].setAttribute(tabindex, -1)
        items[next].setAttribute(tabindex, 0)
        items[next].focus()
      } else if (e.key === ArrowUp  e.key === Up) {
        e.preventDefault()
        var prev = (idx - 1   items.length) % items.length
        items[idx].setAttribute(tabindex, -1)
        items[prev].setAttribute(tabindex, 0)
        items[prev].focus()
      } else if (e.key === Escape  e.key === Esc) {
        e.preventDefault()
        // close the submenu and return focus to the controlling top-level item
        var controllingId = submenu.id
        var controlling = document.querySelector([aria-controls=   controllingId   ])
        if (controlling) {
          closeSubmenu(controlling, true)
        }
      } else if (e.key === ArrowLeft  e.key === Left) {
        // close submenu and move focus to previous top-level
        e.preventDefault()
        var controlling2 = document.querySelector([aria-controls=   submenu.id   ])
        if (controlling2) {
          // find top-level index
          var topIdx = topLevelItems.indexOf(controlling2)
          var prevTop = (topIdx - 1   topLevelItems.length) % topLevelItems.length
          closeSubmenu(controlling2, false)
          moveTopFocus(topIdx, prevTop)
        }
      } else if (e.key === ArrowRight  e.key === Right) {
        // close submenu and move focus to next top-level
        e.preventDefault()
        var controlling3 = document.querySelector([aria-controls=   submenu.id   ])
        if (controlling3) {
          var topIdx2 = topLevelItems.indexOf(controlling3)
          var nextTop = (topIdx2   1) % topLevelItems.length
          closeSubmenu(controlling3, false)
          moveTopFocus(topIdx2, nextTop)
        }
      }
    }
  })

  // Click handling to open/close submenus
  topLevelItems.forEach(function (item) {
    item.addEventListener(click, function (e) {
      var submenu = getSubmenu(item)
      if (submenu) {
        e.preventDefault()
        var expanded = item.getAttribute(aria-expanded) === true
        if (expanded) {
          closeSubmenu(item, true)
        } else {
          openSubmenu(item)
        }
      }
    })
  })

  // Initialize: hide submenus
  topLevelItems.forEach(function (item) {
    var submenu = getSubmenu(item)
    if (submenu) {
      submenu.hidden = true
      submenu.setAttribute(aria-hidden, true)
      var submenuItems = submenu.querySelectorAll([role=menuitem])
      submenuItems.forEach(function (si) { si.setAttribute(tabindex, -1) })
    }
  })
})()

Progressive enhancement and no-JS fallback

  1. Start with simple HTML links (so keyboard and screen reader users can navigate without JS).
  2. Use a script to add advanced behaviors. When JS runs, adjust ARIA attributes, add event listeners, and hide menus visually with CSS (e.g., add a class like .js-enabled to the document root).
  3. Do not remove links from the DOM when adding ARIA simply toggle display/hidden and tabindex so functionality remains if JS fails.

WordPress integration (enqueue the script style)

Place your JavaScript in a file (e.g., accessible-menu.js) and CSS in accessible-menu.css. Enqueue them properly in your theme or plugin.

// functions.php or plugin file
function mytheme_enqueue_accessible_menu() {
  // Assuming files exist in themes js and css directories
  wp_enqueue_script(
    mytheme-accessible-menu,
    get_template_directory_uri() . /js/accessible-menu.js,
    array(),
    1.0.0,
    true
  )
  wp_enqueue_style(
    mytheme-accessible-menu-style,
    get_template_directory_uri() . /css/accessible-menu.css,
    array(),
    1.0.0
  )

  // Optionally pass selectors or options to script
  wp_localize_script(mytheme-accessible-menu, AccessibleMenuConfig, array(
    menubarSelector => #siteMenubar,
    menuButtonSelector => #mainMenuButton
  ))
}
add_action(wp_enqueue_scripts, mytheme_enqueue_accessible_menu)

Testing checklist (manual)

  • Keyboard only: Tab to the menu button/first menubar item. Use Arrow keys, Enter, Space, Escape, Home, and End. Ensure behavior matches expectations.
  • Screen readers: Test with NVDA Firefox, VoiceOver Safari. Confirm ARIA announcements (e.g., expanded state changes, role=menu and menuitem are exposed).
  • Focus management: Opening a menu should move focus into the menu closing should return focus to the button or parent item.
  • Click outside should close open menus.
  • Multiple menus: ensure unique IDs for aria-controls values and independent behavior.

Common pitfalls and how to avoid them

  1. Using role=menu for primary site navigation: Menus with role=menu are application-style widgets and can change how screen readers present them. For typical page nav consider using native nav ul/ol with links and only apply the ARIA menu pattern to widget-like popup menus or toolbar menubars.
  2. Forgetting aria-expanded: Keep aria-expanded in sync with UI state otherwise screen reader users wont know whether a menu is open.
  3. Trapping focus: Dont prevent Tab from leaving the menu. Menus should be usable but not trap keyboard users indefinitely.
  4. Missing unique ids: Each aria-controls target must have a unique id.
  5. Using click-only handlers: Ensure keyboard activation is implemented many mobile-first scripts forget keyboard users.

Security and robustness notes

  • Prefer element methods like textContent over innerHTML when updating labels to avoid XSS if any content comes from user input.
  • Use event delegation carefully unbind when necessary. In WordPress, ensure your script doesnt conflict with other plugins by scoping selectors or namespacing functions.
  • Graceful failure: if ARIA attributes are misused, behavior can degrade. Test with accessibility tools and validators.

Extras: type-ahead (optional)

For long menus, consider implementing type-ahead, a behavior where typing letters moves focus to the first item whose label starts with the typed sequence. Keep a short timeout (e.g., 500ms) to reset the typed buffer.

Simple algorithm for type-ahead

  1. On keydown, if the key is printable, append to buffer.
  2. Search visible menu items for a match starting with the buffer (case-insensitive).
  3. If found, move focus to matched item.
  4. Reset buffer after timeout.

Final checklist before publishing

  • Unique IDs for controls and menus
  • aria-expanded and aria-hidden are updated together with visibility
  • tabindex is adjusted for roving focus
  • Keyboard handling covers Arrow keys, Home/End, Enter/Space, Escape
  • No reliance on hover alone — support keyboard and touch
  • Test with screen readers and keyboard-only navigation

Conclusion

Implementing accessible menus in WordPress requires semantic HTML for a no-JS baseline, ARIA attributes to expose relationships and state, and robust JavaScript to handle keyboard interactions and focus management. Use the provided examples as a foundation: adapt selectors to your theme, ensure unique IDs, and test with assistive technologies. A well-implemented accessible menu improves usability for everyone, not just people using assistive tech.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *