I’ll build this as a complete, reusable inc/js/mobile-menu.js for RX Theme: accessible mobile menu, dropdowns, keyboard support, overlay, body scroll lock, resize handling, focus trap, escape close, outside click, and safe fallbacks.
Yes friend — create this file:
rx-theme/inc/js/mobile-menu.js
This is an advanced mobile menu JavaScript file for RX Theme with many possible useful functions included.
/*!
* RX Theme Mobile Menu
* File: inc/js/mobile-menu.js
* Theme: RX Theme
* Author: RxHarun
*
* Features:
* - Mobile menu open/close
* - Accessible ARIA support
* - Keyboard support
* - Escape key close
* - Outside click close
* - Overlay support
* - Body scroll lock
* - Focus trap
* - Dropdown/submenu toggle
* - Multi-level menu support
* - Auto close on resize
* - Auto close on link click
* - Sticky header helper class
* - Active menu item helper
* - Safe fallback if elements missing
*/
(function () {
'use strict';
/**
* RX Mobile Menu Configuration
*/
const RXMobileMenu = {
selectors: {
body: 'body',
header: '.site-header',
menuToggle: '.rx-mobile-menu-toggle',
menuClose: '.rx-mobile-menu-close',
mobileMenu: '.rx-mobile-menu',
menuOverlay: '.rx-mobile-menu-overlay',
menuWrapper: '.rx-mobile-menu-wrapper',
menuLink: '.rx-mobile-menu a',
menuItemHasChildren: '.rx-mobile-menu .menu-item-has-children',
submenu: '.sub-menu',
dropdownToggle: '.rx-submenu-toggle',
focusableElements:
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
},
classes: {
menuOpen: 'rx-mobile-menu-open',
menuActive: 'is-active',
submenuOpen: 'submenu-open',
bodyLocked: 'rx-body-locked',
overlayActive: 'is-active',
headerSticky: 'rx-header-sticky',
hasJS: 'rx-js-enabled',
focusVisible: 'rx-focus-visible',
},
settings: {
breakpoint: 1024,
closeOnLinkClick: true,
closeOnOutsideClick: true,
closeOnEscape: true,
trapFocus: true,
lockBodyScroll: true,
allowMultipleSubmenus: false,
stickyHeader: true,
stickyOffset: 60,
transitionDuration: 300,
},
state: {
isOpen: false,
lastFocusedElement: null,
scrollPosition: 0,
firstFocusableElement: null,
lastFocusableElement: null,
},
elements: {},
/**
* Initialize mobile menu
*/
init: function () {
this.cacheElements();
this.addJSClass();
if (!this.elements.menuToggle || !this.elements.mobileMenu) {
return;
}
this.setupAccessibility();
this.createOverlayIfMissing();
this.createCloseButtonIfMissing();
this.createSubmenuToggles();
this.bindEvents();
this.setActiveMenuItem();
this.handleStickyHeader();
document.dispatchEvent(
new CustomEvent('rxMobileMenuReady', {
detail: {
menu: this.elements.mobileMenu,
},
})
);
},
/**
* Cache DOM elements
*/
cacheElements: function () {
this.elements.body = document.querySelector(this.selectors.body);
this.elements.header = document.querySelector(this.selectors.header);
this.elements.menuToggle = document.querySelector(this.selectors.menuToggle);
this.elements.mobileMenu = document.querySelector(this.selectors.mobileMenu);
this.elements.menuClose = document.querySelector(this.selectors.menuClose);
this.elements.menuOverlay = document.querySelector(this.selectors.menuOverlay);
this.elements.menuWrapper = document.querySelector(this.selectors.menuWrapper);
},
/**
* Add JS enabled class
*/
addJSClass: function () {
if (this.elements.body) {
this.elements.body.classList.add(this.classes.hasJS);
}
},
/**
* Setup accessibility attributes
*/
setupAccessibility: function () {
const menuId = this.elements.mobileMenu.id || 'rx-mobile-menu';
this.elements.mobileMenu.id = menuId;
this.elements.mobileMenu.setAttribute('aria-hidden', 'true');
this.elements.menuToggle.setAttribute('aria-controls', menuId);
this.elements.menuToggle.setAttribute('aria-expanded', 'false');
this.elements.menuToggle.setAttribute('aria-label', 'Open mobile menu');
this.elements.menuToggle.setAttribute('type', 'button');
if (this.elements.menuClose) {
this.elements.menuClose.setAttribute('aria-label', 'Close mobile menu');
this.elements.menuClose.setAttribute('type', 'button');
}
},
/**
* Create overlay if not already available
*/
createOverlayIfMissing: function () {
if (!this.elements.menuOverlay) {
const overlay = document.createElement('div');
overlay.className = this.selectors.menuOverlay.replace('.', '');
overlay.setAttribute('aria-hidden', 'true');
document.body.appendChild(overlay);
this.elements.menuOverlay = overlay;
}
},
/**
* Create close button if missing
*/
createCloseButtonIfMissing: function () {
if (!this.elements.menuClose && this.elements.mobileMenu) {
const closeButton = document.createElement('button');
closeButton.className = this.selectors.menuClose.replace('.', '');
closeButton.setAttribute('type', 'button');
closeButton.setAttribute('aria-label', 'Close mobile menu');
closeButton.innerHTML = '<span aria-hidden="true">×</span>';
this.elements.mobileMenu.insertBefore(
closeButton,
this.elements.mobileMenu.firstChild
);
this.elements.menuClose = closeButton;
}
},
/**
* Create submenu buttons for menu items with children
*/
createSubmenuToggles: function () {
const parentItems = document.querySelectorAll(
this.selectors.menuItemHasChildren
);
parentItems.forEach((item, index) => {
const link = item.querySelector('a');
const submenu = item.querySelector(this.selectors.submenu);
if (!submenu) {
return;
}
const submenuId = submenu.id || 'rx-submenu-' + index;
submenu.id = submenuId;
submenu.setAttribute('aria-hidden', 'true');
let toggle = item.querySelector(this.selectors.dropdownToggle);
if (!toggle) {
toggle = document.createElement('button');
toggle.className = this.selectors.dropdownToggle.replace('.', '');
toggle.setAttribute('type', 'button');
toggle.setAttribute('aria-expanded', 'false');
toggle.setAttribute('aria-controls', submenuId);
toggle.setAttribute('aria-label', 'Open submenu');
toggle.innerHTML =
'<span class="rx-submenu-icon" aria-hidden="true"></span>';
if (link) {
link.insertAdjacentElement('afterend', toggle);
} else {
item.insertBefore(toggle, submenu);
}
}
});
},
/**
* Bind all events
*/
bindEvents: function () {
const self = this;
this.elements.menuToggle.addEventListener('click', function (event) {
event.preventDefault();
self.toggleMenu();
});
if (this.elements.menuClose) {
this.elements.menuClose.addEventListener('click', function (event) {
event.preventDefault();
self.closeMenu();
});
}
if (this.elements.menuOverlay) {
this.elements.menuOverlay.addEventListener('click', function () {
if (self.settings.closeOnOutsideClick) {
self.closeMenu();
}
});
}
document.addEventListener('click', function (event) {
self.handleOutsideClick(event);
});
document.addEventListener('keydown', function (event) {
self.handleKeyboard(event);
});
document.addEventListener('focusin', function (event) {
self.handleFocusIn(event);
});
window.addEventListener(
'resize',
this.debounce(function () {
self.handleResize();
}, 150)
);
window.addEventListener(
'scroll',
this.throttle(function () {
self.handleStickyHeader();
}, 100)
);
const submenuToggles = document.querySelectorAll(
this.selectors.dropdownToggle
);
submenuToggles.forEach((toggle) => {
toggle.addEventListener('click', function (event) {
event.preventDefault();
event.stopPropagation();
self.toggleSubmenu(toggle);
});
});
const menuLinks = document.querySelectorAll(this.selectors.menuLink);
menuLinks.forEach((link) => {
link.addEventListener('click', function () {
self.handleMenuLinkClick(link);
});
});
},
/**
* Toggle menu
*/
toggleMenu: function () {
if (this.state.isOpen) {
this.closeMenu();
} else {
this.openMenu();
}
},
/**
* Open menu
*/
openMenu: function () {
if (this.state.isOpen) {
return;
}
this.state.lastFocusedElement = document.activeElement;
this.state.isOpen = true;
this.elements.body.classList.add(this.classes.menuOpen);
this.elements.mobileMenu.classList.add(this.classes.menuActive);
this.elements.mobileMenu.setAttribute('aria-hidden', 'false');
this.elements.menuToggle.classList.add(this.classes.menuActive);
this.elements.menuToggle.setAttribute('aria-expanded', 'true');
this.elements.menuToggle.setAttribute('aria-label', 'Close mobile menu');
if (this.elements.menuOverlay) {
this.elements.menuOverlay.classList.add(this.classes.overlayActive);
}
if (this.settings.lockBodyScroll) {
this.lockBodyScroll();
}
this.updateFocusableElements();
const self = this;
window.setTimeout(function () {
self.focusFirstElement();
}, 50);
document.dispatchEvent(
new CustomEvent('rxMobileMenuOpened', {
detail: {
menu: this.elements.mobileMenu,
},
})
);
},
/**
* Close menu
*/
closeMenu: function () {
if (!this.state.isOpen) {
return;
}
this.state.isOpen = false;
this.elements.body.classList.remove(this.classes.menuOpen);
this.elements.mobileMenu.classList.remove(this.classes.menuActive);
this.elements.mobileMenu.setAttribute('aria-hidden', 'true');
this.elements.menuToggle.classList.remove(this.classes.menuActive);
this.elements.menuToggle.setAttribute('aria-expanded', 'false');
this.elements.menuToggle.setAttribute('aria-label', 'Open mobile menu');
if (this.elements.menuOverlay) {
this.elements.menuOverlay.classList.remove(this.classes.overlayActive);
}
if (this.settings.lockBodyScroll) {
this.unlockBodyScroll();
}
this.closeAllSubmenus();
if (
this.state.lastFocusedElement &&
typeof this.state.lastFocusedElement.focus === 'function'
) {
this.state.lastFocusedElement.focus();
}
document.dispatchEvent(
new CustomEvent('rxMobileMenuClosed', {
detail: {
menu: this.elements.mobileMenu,
},
})
);
},
/**
* Lock body scroll when menu is open
*/
lockBodyScroll: function () {
this.state.scrollPosition = window.pageYOffset || document.documentElement.scrollTop;
this.elements.body.classList.add(this.classes.bodyLocked);
this.elements.body.style.top = '-' + this.state.scrollPosition + 'px';
this.elements.body.style.position = 'fixed';
this.elements.body.style.width = '100%';
this.elements.body.style.overflow = 'hidden';
},
/**
* Unlock body scroll when menu is closed
*/
unlockBodyScroll: function () {
this.elements.body.classList.remove(this.classes.bodyLocked);
this.elements.body.style.position = '';
this.elements.body.style.top = '';
this.elements.body.style.width = '';
this.elements.body.style.overflow = '';
window.scrollTo(0, this.state.scrollPosition);
},
/**
* Toggle submenu
*/
toggleSubmenu: function (toggle) {
const parentItem = toggle.closest(this.selectors.menuItemHasChildren);
const submenuId = toggle.getAttribute('aria-controls');
const submenu = document.getElementById(submenuId);
if (!parentItem || !submenu) {
return;
}
const isOpen = parentItem.classList.contains(this.classes.submenuOpen);
if (!this.settings.allowMultipleSubmenus) {
this.closeSiblingSubmenus(parentItem);
}
if (isOpen) {
this.closeSubmenu(parentItem, submenu, toggle);
} else {
this.openSubmenu(parentItem, submenu, toggle);
}
},
/**
* Open submenu
*/
openSubmenu: function (parentItem, submenu, toggle) {
parentItem.classList.add(this.classes.submenuOpen);
submenu.setAttribute('aria-hidden', 'false');
toggle.setAttribute('aria-expanded', 'true');
toggle.setAttribute('aria-label', 'Close submenu');
const submenuHeight = submenu.scrollHeight;
submenu.style.maxHeight = submenuHeight + 'px';
document.dispatchEvent(
new CustomEvent('rxSubmenuOpened', {
detail: {
item: parentItem,
submenu: submenu,
},
})
);
},
/**
* Close submenu
*/
closeSubmenu: function (parentItem, submenu, toggle) {
parentItem.classList.remove(this.classes.submenuOpen);
submenu.setAttribute('aria-hidden', 'true');
toggle.setAttribute('aria-expanded', 'false');
toggle.setAttribute('aria-label', 'Open submenu');
submenu.style.maxHeight = '';
const childSubmenus = parentItem.querySelectorAll(
'.' + this.classes.submenuOpen
);
childSubmenus.forEach((childItem) => {
const childSubmenu = childItem.querySelector(this.selectors.submenu);
const childToggle = childItem.querySelector(this.selectors.dropdownToggle);
if (childSubmenu && childToggle) {
this.closeSubmenu(childItem, childSubmenu, childToggle);
}
});
document.dispatchEvent(
new CustomEvent('rxSubmenuClosed', {
detail: {
item: parentItem,
submenu: submenu,
},
})
);
},
/**
* Close sibling submenus
*/
closeSiblingSubmenus: function (parentItem) {
const siblings = Array.from(parentItem.parentElement.children).filter(
(item) => item !== parentItem
);
siblings.forEach((sibling) => {
if (sibling.classList.contains(this.classes.submenuOpen)) {
const submenu = sibling.querySelector(this.selectors.submenu);
const toggle = sibling.querySelector(this.selectors.dropdownToggle);
if (submenu && toggle) {
this.closeSubmenu(sibling, submenu, toggle);
}
}
});
},
/**
* Close all submenus
*/
closeAllSubmenus: function () {
const openedItems = document.querySelectorAll(
this.selectors.menuItemHasChildren + '.' + this.classes.submenuOpen
);
openedItems.forEach((item) => {
const submenu = item.querySelector(this.selectors.submenu);
const toggle = item.querySelector(this.selectors.dropdownToggle);
if (submenu && toggle) {
this.closeSubmenu(item, submenu, toggle);
}
});
},
/**
* Handle menu link click
*/
handleMenuLinkClick: function (link) {
const href = link.getAttribute('href');
if (!this.settings.closeOnLinkClick) {
return;
}
if (!href || href === '#') {
return;
}
const parentItem = link.closest(this.selectors.menuItemHasChildren);
const submenu = parentItem
? parentItem.querySelector(this.selectors.submenu)
: null;
if (submenu && link.nextElementSibling) {
return;
}
this.closeMenu();
},
/**
* Handle outside click
*/
handleOutsideClick: function (event) {
if (!this.state.isOpen || !this.settings.closeOnOutsideClick) {
return;
}
const clickedInsideMenu = this.elements.mobileMenu.contains(event.target);
const clickedToggle = this.elements.menuToggle.contains(event.target);
if (!clickedInsideMenu && !clickedToggle) {
this.closeMenu();
}
},
/**
* Handle keyboard events
*/
handleKeyboard: function (event) {
if (!this.state.isOpen) {
return;
}
if (event.key === 'Escape' && this.settings.closeOnEscape) {
this.closeMenu();
return;
}
if (event.key === 'Tab' && this.settings.trapFocus) {
this.trapFocus(event);
}
},
/**
* Update focusable elements
*/
updateFocusableElements: function () {
const focusableElements = this.elements.mobileMenu.querySelectorAll(
this.selectors.focusableElements
);
const visibleFocusableElements = Array.from(focusableElements).filter(
(element) => {
return (
element.offsetWidth > 0 ||
element.offsetHeight > 0 ||
element === document.activeElement
);
}
);
this.state.firstFocusableElement = visibleFocusableElements[0] || null;
this.state.lastFocusableElement =
visibleFocusableElements[visibleFocusableElements.length - 1] || null;
},
/**
* Focus first element
*/
focusFirstElement: function () {
this.updateFocusableElements();
if (this.state.firstFocusableElement) {
this.state.firstFocusableElement.focus();
} else {
this.elements.mobileMenu.focus();
}
},
/**
* Trap focus inside mobile menu
*/
trapFocus: function (event) {
this.updateFocusableElements();
const firstElement = this.state.firstFocusableElement;
const lastElement = this.state.lastFocusableElement;
if (!firstElement || !lastElement) {
return;
}
if (event.shiftKey) {
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
},
/**
* Handle focus inside menu
*/
handleFocusIn: function (event) {
if (!this.state.isOpen || !this.settings.trapFocus) {
return;
}
if (
this.elements.mobileMenu &&
!this.elements.mobileMenu.contains(event.target) &&
!this.elements.menuToggle.contains(event.target)
) {
this.focusFirstElement();
}
},
/**
* Handle browser resize
*/
handleResize: function () {
if (window.innerWidth >= this.settings.breakpoint && this.state.isOpen) {
this.closeMenu();
}
this.refreshOpenSubmenuHeights();
},
/**
* Refresh submenu height after resize
*/
refreshOpenSubmenuHeights: function () {
const openedSubmenus = document.querySelectorAll(
this.selectors.menuItemHasChildren +
'.' +
this.classes.submenuOpen +
' ' +
this.selectors.submenu
);
openedSubmenus.forEach((submenu) => {
submenu.style.maxHeight = submenu.scrollHeight + 'px';
});
},
/**
* Sticky header class
*/
handleStickyHeader: function () {
if (!this.settings.stickyHeader || !this.elements.header) {
return;
}
const scrollTop =
window.pageYOffset || document.documentElement.scrollTop || 0;
if (scrollTop > this.settings.stickyOffset) {
this.elements.header.classList.add(this.classes.headerSticky);
} else {
this.elements.header.classList.remove(this.classes.headerSticky);
}
},
/**
* Add current active helper to menu item
*/
setActiveMenuItem: function () {
const currentURL = window.location.href;
const currentPath = window.location.pathname;
const menuLinks = document.querySelectorAll(this.selectors.menuLink);
menuLinks.forEach((link) => {
const linkURL = link.href;
const linkPath = link.pathname;
if (linkURL === currentURL || linkPath === currentPath) {
link.classList.add('rx-current-menu-link');
const item = link.closest('li');
if (item) {
item.classList.add('rx-current-menu-item');
}
const parentItems = link.closest(this.selectors.menuItemHasChildren);
if (parentItems) {
parentItems.classList.add('rx-current-menu-parent');
}
}
});
},
/**
* Debounce helper
*/
debounce: function (callback, delay) {
let timer;
return function () {
const context = this;
const args = arguments;
clearTimeout(timer);
timer = setTimeout(function () {
callback.apply(context, args);
}, delay);
};
},
/**
* Throttle helper
*/
throttle: function (callback, limit) {
let waiting = false;
return function () {
if (!waiting) {
callback.apply(this, arguments);
waiting = true;
setTimeout(function () {
waiting = false;
}, limit);
}
};
},
};
/**
* Initialize when DOM is ready
*/
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
RXMobileMenu.init();
});
} else {
RXMobileMenu.init();
}
/**
* Make available globally for advanced use
*/
window.RXMobileMenu = RXMobileMenu;
})();
Use this HTML structure in your theme header:
<button class="rx-mobile-menu-toggle" type="button">
<span class="rx-menu-icon"></span>
<span class="screen-reader-text">Menu</span>
</button>
<nav class="rx-mobile-menu" role="navigation">
<?php
wp_nav_menu(array(
'theme_location' => 'primary',
'menu_class' => 'rx-mobile-menu-list',
'container' => false,
));
?>
</nav>
Enqueue it in functions.php or inc/core/enqueue.php:
function rx_theme_enqueue_mobile_menu_script() {
wp_enqueue_script(
'rx-mobile-menu',
get_template_directory_uri() . '/inc/js/mobile-menu.js',
array(),
wp_get_theme()->get('Version'),
true
);
}
add_action('wp_enqueue_scripts', 'rx_theme_enqueue_mobile_menu_script');
Helpful CSS starter:
.rx-mobile-menu {
position: fixed;
top: 0;
right: -100%;
width: 85%;
max-width: 380px;
height: 100vh;
background: #fff;
z-index: 99999;
overflow-y: auto;
transition: right 0.3s ease;
padding: 24px;
}
.rx-mobile-menu.is-active {
right: 0;
}
.rx-mobile-menu-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 99998;
opacity: 0;
visibility: hidden;
transition: 0.3s ease;
}
.rx-mobile-menu-overlay.is-active {
opacity: 1;
visibility: visible;
}
.rx-body-locked {
overflow: hidden;
}
.rx-mobile-menu-close {
display: block;
margin-left: auto;
font-size: 32px;
background: transparent;
border: 0;
cursor: pointer;
}
.rx-mobile-menu .sub-menu {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.rx-submenu-toggle {
background: transparent;
border: 0;
cursor: pointer;
margin-left: 8px;
}
.rx-submenu-icon::before {
content: "+";
}
.submenu-open > a + .rx-submenu-toggle .rx-submenu-icon::before {
content: "-";
}
This mobile-menu.js is safe for a professional WordPress theme and can grow with your RX Theme.