WordPress renders navigation menus by walking a tree of menu items and outputting HTML. The default output — a nested <ul> and <li> structure — works with many CSS frameworks, but modern design systems frequently need specific markup: Bootstrap 5 requires dropdown-menu classes and data-bs-toggle attributes, custom mega-menus need wrapper divs around sub-menus, and accessible hamburger patterns sometimes need the toggle button inside the <li> rather than outside. WordPress’s Walker_Nav_Menu class controls every piece of this HTML. By extending it and overriding one or more of its four methods — start_lvl(), end_lvl(), start_el(), and end_el() — you can produce any markup structure without touching the core walker or using regex on the output string. This article builds a Bootstrap 5-compatible walker as a practical example, then shows how to register it with wp_nav_menu().
Problem: The default WordPress menu walker outputs plain <ul><li> markup that does not match your CSS framework's required structure — for example Bootstrap 5 dropdowns need specific class names and data-bs-toggle attributes.
Solution: Extend Walker_Nav_Menu and override start_el() and start_lvl() to output the markup your framework requires. Pass the custom walker to wp_nav_menu() via the 'walker' argument.
<?php
class Bootstrap5_Walker_Nav_Menu extends Walker_Nav_Menu {
// Opening <ul> for a sub-menu level
public function start_lvl( &$output, $depth = 0, $args = null ) {
$output .= '<ul class="dropdown-menu">';
}
// Each menu item <li> and its <a> or <button>
public function start_el( &$output, $item, $depth = 0, $args = null, $id = 0 ) {
$has_children = in_array( 'menu-item-has-children', $item->classes );
$active_class = in_array( 'current-menu-item', $item->classes ) ? ' active' : '';
$li_class = $has_children ? 'nav-item dropdown' : 'nav-item';
$output .= '<li class="' . esc_attr( $li_class ) . '">';
if ( $has_children && $depth === 0 ) {
// Top-level parent: render as a dropdown toggle button
$output .= '<a class="nav-link dropdown-toggle' . esc_attr( $active_class ) . '"'
. ' href="' . esc_url( $item->url ) . '"'
. ' data-bs-toggle="dropdown"'
. ' aria-expanded="false">'
. esc_html( $item->title )
. '</a>';
} elseif ( $depth > 0 ) {
// Dropdown child item
$output .= '<a class="dropdown-item' . esc_attr( $active_class ) . '"'
. ' href="' . esc_url( $item->url ) . '">'
. esc_html( $item->title )
. '</a>';
} else {
// Regular top-level item
$output .= '<a class="nav-link' . esc_attr( $active_class ) . '"'
. ' href="' . esc_url( $item->url ) . '">'
. esc_html( $item->title )
. '</a>';
}
}
}
// Usage in a template:
wp_nav_menu( [
'theme_location' => 'primary',
'container' => false,
'menu_class' => 'navbar-nav me-auto',
'walker' => new Bootstrap5_Walker_Nav_Menu(),
] );
NOTE: This walker strips the default WordPress ID and class attributes from <li> elements to keep the HTML clean. If you need to keep them — for example to support JavaScript that targets specific menu items — override start_el() by calling parent::start_el(...) first and then modifying the $output string. Alternatively, add a nav_menu_css_class filter to append classes to the default output rather than replacing the walker entirely.