In this article
Multi-level menus are easy on a desktop, where submenus open on hover. On a phone there is no hover, and a menu with three levels quickly becomes unusable: submenus that never open, or open all at once in a long list. In this tutorial I build a mobile-friendly multi-level WordPress menu with a custom walker class and a small jQuery script, without a menu plugin. It's the kind of thing I do in custom themes as a freelance WordPress developer.
This is a rewritten version of a tutorial from my old blog, updated for current WordPress and with better accessibility.
What we are building
- On mobile, a "Menu" button opens the navigation, and each item with children gets its own toggle button to open its submenu, at any depth.
- Only one submenu per level is open at a time, so the menu stays short.
- On desktop, the same markup works as a normal horizontal menu with hover and keyboard dropdowns.
- Buttons use
aria-expanded, so screen readers know what is open, and the Escape key closes the menu.
Why a custom walker?
wp_nav_menu() builds the menu HTML with a class called Walker_Nav_Menu. To add a toggle button next to every link that has a submenu, we extend that class and change only what we need. The alternative, adding buttons with JavaScript after the page loads, works, but it means the buttons are missing until the script runs and the markup is harder to style. A walker gives you the right HTML from the server.
Step 1: the walker class
Add this to your theme, for example in inc/class-st-mobile-menu-walker.php, and load it from functions.php with require_once. It calls the parent class to build each item as WordPress normally does, then adds a button when the item has children. WordPress adds the menu-item-has-children class to those items, which is what we check.
<?php
/**
* Adds a toggle button to every menu item that has a submenu,
* so submenus can be opened by tap and by keyboard on mobile.
*/
class ST_Mobile_Menu_Walker extends Walker_Nav_Menu {
public function start_lvl( &$output, $depth = 0, $args = null ) {
$indent = str_repeat( "\t", $depth );
$output .= "\n{$indent}<ul class=\"sub-menu\">\n";
}
public function start_el( &$output, $data_object, $depth = 0, $args = null, $current_object_id = 0 ) {
// Let WordPress build the <li> and <a> as usual.
parent::start_el( $output, $data_object, $depth, $args, $current_object_id );
if ( in_array( 'menu-item-has-children', (array) $data_object->classes, true ) ) {
$label = sprintf(
/* translators: %s: menu item title */
__( 'Show submenu for %s', 'your-textdomain' ),
wp_strip_all_tags( $data_object->title )
);
$output .= sprintf(
'<button type="button" class="submenu-toggle" aria-expanded="false" aria-label="%s"><span aria-hidden="true"></span></button>',
esc_attr( $label )
);
}
}
}
The button gets a readable label ("Show submenu for Services") so it makes sense to screen reader users, and the chevron is drawn with CSS inside an empty span.
Step 2: output the menu with the walker
In header.php, where your menu is printed, add the menu button and pass the walker to wp_nav_menu(). The container_id matches the button's aria-controls.
<button type="button" class="menu-toggle" aria-expanded="false" aria-controls="site-nav">
<?php esc_html_e( 'Menu', 'your-textdomain' ); ?>
</button>
<?php
wp_nav_menu( array(
'theme_location' => 'primary',
'container' => 'nav',
'container_id' => 'site-nav',
'container_class' => 'site-nav',
'menu_class' => 'menu',
'walker' => new ST_Mobile_Menu_Walker(),
) );
Make sure the primary menu location is registered with register_nav_menus() in your theme, and that a menu is assigned to it under Appearance → Menus, or in the site editor for block themes that still use classic menus.
Step 3: load the script
WordPress ships with jQuery, so we declare it as a dependency and load our script in the footer:
add_action( 'wp_enqueue_scripts', function () {
wp_enqueue_script(
'st-mobile-menu',
get_theme_file_uri( 'js/mobile-menu.js' ),
array( 'jquery' ),
'1.0.0',
true
);
} );
Step 4: the jQuery
Save this as js/mobile-menu.js in your theme. It handles three things: the main menu button, the submenu toggles at any depth, and the Escape key. Event delegation ($nav.on('click', '.submenu-toggle', ...)) means it works for every level without extra code.
jQuery(function ($) {
var $nav = $('#site-nav');
var $menuToggle = $('.menu-toggle');
function closeSubmenus($scope) {
$scope.find('li.is-open').removeClass('is-open')
.children('.submenu-toggle').attr('aria-expanded', 'false');
}
// Open and close the whole menu on mobile.
$menuToggle.on('click', function () {
var open = $menuToggle.attr('aria-expanded') === 'true';
$menuToggle.attr('aria-expanded', String(!open));
$nav.toggleClass('is-open', !open);
if (open) closeSubmenus($nav);
});
// Open one submenu at a time on each level.
$nav.on('click', '.submenu-toggle', function () {
var $btn = $(this);
var $li = $btn.parent('li');
var open = $btn.attr('aria-expanded') === 'true';
// Close this level (siblings, this item and everything below it) first.
closeSubmenus($li.parent());
$btn.attr('aria-expanded', String(!open));
$li.toggleClass('is-open', !open);
});
// Escape closes everything and returns focus to the menu button.
$(document).on('keydown', function (e) {
if (e.key === 'Escape' && $nav.hasClass('is-open')) {
closeSubmenus($nav);
$nav.removeClass('is-open');
$menuToggle.attr('aria-expanded', 'false').trigger('focus');
}
});
});
When a submenu opens, its open siblings and anything nested inside them close first, so only one branch is open per level. Closing a parent also closes its children, so the menu is tidy the next time it opens.
Step 5: the CSS
The CSS does the real show-and-hide work. The script only adds and removes the is-open class. Adjust the breakpoint, colours and spacing to match your theme.
.site-nav ul { list-style: none; margin: 0; padding: 0; }
.site-nav li { position: relative; }
.submenu-toggle {
width: 44px; height: 44px;
border: 0; background: none; cursor: pointer;
}
.submenu-toggle span {
display: inline-block; width: 8px; height: 8px;
border: solid currentColor; border-width: 0 2px 2px 0;
transform: rotate(45deg); transition: transform 0.2s;
}
.submenu-toggle[aria-expanded="true"] span { transform: rotate(-135deg); }
/* Mobile: stacked menu, submenus open on tap */
@media (max-width: 959px) {
.site-nav { display: none; }
.site-nav.is-open { display: block; }
.site-nav li { display: flex; flex-wrap: wrap; align-items: center; }
.site-nav li > a { flex: 1; padding: 0.75rem 0; }
.site-nav .sub-menu { display: none; flex-basis: 100%; padding-left: 1rem; }
.site-nav li.is-open > .sub-menu { display: block; }
}
/* Desktop: horizontal menu, submenus open on hover and keyboard focus */
@media (min-width: 960px) {
.menu-toggle, .submenu-toggle { display: none; }
.site-nav .menu { display: flex; gap: 1.5rem; }
.site-nav .sub-menu {
display: none; position: absolute; top: 100%; left: 0; z-index: 10;
min-width: 14rem; padding: 0.5rem 0; background: #fff;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.site-nav .sub-menu a { display: block; padding: 0.5rem 1rem; }
.site-nav .sub-menu .sub-menu { top: 0; left: 100%; }
.site-nav li:hover > .sub-menu,
.site-nav li:focus-within > .sub-menu { display: block; }
}
Two details matter on mobile. The toggle buttons are 44 by 44 pixels, a comfortable size to tap. And each link stays a normal link: tapping "Services" goes to the Services page, while tapping the chevron next to it opens the submenu. Parent items with their own pages remain reachable.
Testing
- Open the menu on a real phone, not only in a narrow desktop window.
- Open a second-level submenu, then a third-level one, then a sibling. Only one branch should stay open.
- Use the keyboard on desktop: Tab through the menu and check that dropdowns appear with
:focus-within. - Try a screen reader. The toggles should announce "collapsed" or "expanded".
- Check that nothing jumps when the page loads, because the CSS hides submenus before the script runs.
Without jQuery
If your theme doesn't load jQuery anywhere else, it's worth dropping it for this. The same logic in plain JavaScript uses document.querySelector, addEventListener on the nav for delegated clicks, and classList.toggle. The walker and CSS stay exactly the same, which is the point of keeping the markup on the server side.
Common problems
- Nothing happens on tap: check that the script is loading (look for it in the page source) and that the container ID is
site-nav. - Submenus open on desktop and won't close: the desktop media query must hide the toggles and rely on hover and focus.
- Buttons appear on items without children: another plugin may be adding the
menu-item-has-childrenclass; check the menu output.
If you need help with a custom WordPress theme, a menu that misbehaves or a site that has grown too many plugins, see my WordPress development page.
Using the walker in a child theme
If you're working on a theme you didn't build, put the walker class and the script in a child theme, so a theme update doesn't wipe them out. Copy the parent theme's header.php into the child theme, change the wp_nav_menu() call to pass the walker, and enqueue the script from the child theme's functions.php. Remove or disable the parent theme's own mobile menu script, or the two will fight over the same clicks.
Performance
The script is a few hundred bytes and runs once when the page loads. Loading it in the footer, as the enqueue code above does, keeps it from blocking the first paint. If jQuery is only loaded for this menu, switching to plain JavaScript removes a larger file from every page, which helps mobile speed scores.