Contents
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
- Start with a semantic list of links for the no-JS fallback. If JavaScript is disabled, the links are visible and navigable.
- When JavaScript runs, hide expanded menus from visual display and from keyboard/tab order (use CSS classes and tabindex=-1 for hidden items).
- 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.
- Keep ARIA values in sync with UI state: aria-expanded, aria-hidden, tabindex.
- Use keydown events for arrow keys and preventDefault on handled keys. Use click for activation.
- Avoid changing focus in ways that confuse screen-reader virtual cursor modes. Move focus only when opening a menu or navigating items.
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) })()
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
- Start with simple HTML links (so keyboard and screen reader users can navigate without JS).
- 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).
- 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
- 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.
- Forgetting aria-expanded: Keep aria-expanded in sync with UI state otherwise screen reader users wont know whether a menu is open.
- Trapping focus: Dont prevent Tab from leaving the menu. Menus should be usable but not trap keyboard users indefinitely.
- Missing unique ids: Each aria-controls target must have a unique id.
- 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
- On keydown, if the key is printable, append to buffer.
- Search visible menu items for a match starting with the buffer (case-insensitive).
- If found, move focus to matched item.
- 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 🙂 |