Skip to content
ExponaBLOG
WORDPRESS DEVELOPMENT & PLUGINS · DAY 090 / 100

Menus in WordPress: a practical guide for developers

Navigation for classic and block themes.

Expona Admin8 min read

Introduction

Navigation is one of the most fundamental aspects of any website, yet developers often treat it as an afterthought in WordPress. Whether you're building a classic theme with PHP or a modern block theme, the approach to menus differs significantly—and understanding both is essential for delivering polished, accessible user experiences. This guide covers menus in WordPress: a practical guide for developers, exploring how to register, render, and customize navigation across theme architectures while keeping accessibility at the forefront.

Understanding Menu Registration in Classic Themes

In classic WordPress themes, menus begin with registration. Before users can assign menu items in the WordPress admin, your theme must declare which menu locations exist. This happens in your theme's functions.php file using the register_nav_menus() function.

Let's say you're building a corporate site that needs both a primary header navigation and a footer menu. You'd register these locations like this:

php
add_action( 'after_setup_theme', function() {
    register_nav_menus( array(
        'primary'   => esc_html__( 'Primary Menu', 'mytheme' ),
        'footer'    => esc_html__( 'Footer Menu', 'mytheme' ),
        'secondary' => esc_html__( 'Secondary Menu', 'mytheme' ),
    ) );
} );

Once registered, these locations appear under Appearance > Menus in the WordPress admin, allowing content editors to assign menus to specific areas. The string keys ('primary', 'footer', 'secondary') are how you reference these locations in your templates.

In your theme template, you render a registered menu using wp_nav_menu(). Here's a practical example:

php
<nav class="site-navigation">
    <?php
    wp_nav_menu( array(
        'theme_location' => 'primary',
        'container'      => 'div',
        'container_class'=> 'primary-menu-wrapper',
        'menu_class'     => 'menu',
        'depth'          => 3,
        'fallback_cb'    => 'wp_page_menu',
    ) );
    ?>
</nav>

The depth parameter limits menu nesting (3 levels here). The fallback_cb parameter specifies a fallback function if no menu is assigned—in this case, it displays pages as navigation if needed. This prevents your site from having completely broken navigation during theme setup.

Block themes handle menus differently. Instead of register_nav_menus() and template tags, block themes use the Navigation block within the block editor. This shift reflects WordPress's move toward a block-based paradigm where templates themselves are built from blocks.

In a block theme, you don't register menu locations programmatically. Instead, the theme.json file can define menu settings, and editors compose navigation using the Navigation block directly in the Site Editor or in template parts.

Here's how a block theme might define navigation support in theme.json:

php
{
  "version": 2,
  "settings": {
    "appearance": {
      "border": true
    }
  },
  "templateParts": [
    {
      "name": "header",
      "title": "Header"
    },
    {
      "name": "footer",
      "title": "Footer"
    }
  ]
}

Within a template part like parts/header.html, editors drag the Navigation block onto the canvas. The block UI allows them to select which menu to display, set mobile behavior, styling, and more—all without touching code. This democratizes navigation management but requires developers to think about default menu structures and fallback behavior.

If you're creating a block theme for distribution, you might include a default template structure with a pre-populated Navigation block to guide users:

php
<!-- wp:navigation {"overlayMenu":"never"} -->
<div class="wp-block-navigation">
  <!-- wp:navigation-link {"label":"Home","url":"/"} /-->
  <!-- wp:navigation-link {"label":"About","url":"/about"} /-->
</div>
<!-- /wp:navigation -->

Custom Walkers for Markup Changes

Sometimes the default menu markup from wp_nav_menu() doesn't fit your design system or JavaScript framework. This is where custom walkers come in. A walker is a class that traverses the menu tree and outputs custom HTML for each item.

Imagine you're integrating a menu with a third-party dropdown library that requires specific data attributes and class names. You'd create a custom walker:

php
class Custom_Menu_Walker extends Walker_Nav_Menu {
    public function start_el( &$output, $item, $depth = 0, $args = null, $id = 0 ) {
        $indent = ( $depth ) ? str_repeat( "\t", $depth ) : '';
        $classes = empty( $item->classes ) ? array() : (array) $item->classes;
        $classes[] = 'menu-item-' . $item->ID;

        if ( in_array( 'current-menu-item', $classes ) ) {
            $classes[] = 'is-active';
        }

        $class_names = join( ' ', apply_filters( 'nav_menu_css_class', $classes, $item, $args, $depth ) );
        $class_names = $class_names ? ' class="' . esc_attr( $class_names ) . '"' : '';

        $id = apply_filters( 'nav_menu_item_id', 'menu-item-' . $item->ID, $item, $args, $depth );
        $id = $id ? ' id="' . esc_attr( $id ) . '"' : '';

        $output .= $indent . '<li' . $id . $class_names . ' data-menu-id="' . esc_attr( $item->ID ) . '">';

        $atts = array();
        $atts['title']  = ! empty( $item->attr_title ) ? $item->attr_title : '';
        $atts['target'] = ! empty( $item->target ) ? $item->target : '';
        $atts['rel']    = ! empty( $item->xfn ) ? $item->xfn : '';
        $atts['href']   = ! empty( $item->url ) ? $item->url : '';
        $atts['class']  = 'menu-link';

        $atts = apply_filters( 'nav_menu_link_attributes', $atts, $item, $args, $depth );
        $attributes = '';
        foreach ( $atts as $attr => $value ) {
            if ( ! empty( $value ) ) {
                $value = 'href' === $attr ? esc_url( $value ) : esc_attr( $value );
                $attributes .= ' ' . $attr . '="' . $value . '"';
            }
        }

        $title = apply_filters( 'nav_menu_item_title', $item->title, $item, $args, $depth );
        $title = apply_filters( 'nav_menu_link_title', $title, $item, $args, $depth );

        $output .= '<a' . $attributes . '>' . $title . '</a>';
    }
}

add_filter( 'wp_nav_menu_args', function( $args ) {
    $args['walker'] = new Custom_Menu_Walker();
    return $args;
}, 10, 1 );

This walker adds a data-menu-id attribute and an is-active class for current items. You override start_el() to customize how each list item opens, and you can also override end_el() or start_lvl() to change nesting markup. This approach is powerful but requires careful attention to escaping and sanitization to prevent security issues.

Keeping Menus Accessible

Accessible menus aren't an afterthought—they're a core responsibility. Users navigating with keyboards or screen readers need proper semantic markup and ARIA attributes.

WordPress menus are generally semantic by default, but here are practical steps to ensure accessibility:

  • Use proper nav elements: Always wrap menus in a <nav> tag and consider adding aria-label to distinguish multiple navigations on the same page.
  • Add skip links: Include a skip-to-content link at the start of navigation so keyboard users can bypass long menus.
  • Indicate current page: Use both visual styling and aria-current="page" to mark the active menu item.
  • Handle dropdowns: If you have nested menus, ensure keyboard users can navigate them. Use aria-expanded to indicate dropdown state, and manage focus properly with JavaScript.

Here's a practical example combining these principles:

php
<nav class="site-navigation" aria-label="Main Navigation">
    <a href="#main-content" class="skip-link">Skip to content</a>
    <?php
    wp_nav_menu( array(
        'theme_location' => 'primary',
        'container'      => false,
        'menu_class'     => 'primary-menu',
        'items_wrap'     => '<ul class="%2$s">%3$s</ul>',
        'depth'          => 3,
    ) );
    ?>
</nav>

The skip link allows screen reader and keyboard users to jump past navigation. The aria-label identifies the nav purpose. When you output current pages, WordPress automatically adds the current-menu-item class—enhance this by using a custom walker or filter to also add aria-current="page".

For dropdown menus, use JavaScript to manage focus and toggle aria-expanded on parent items as users navigate:

php
document.querySelectorAll('.menu-item-has-children > a').forEach( link => {
    link.addEventListener( 'click', function( e ) {
        e.preventDefault();
        const expanded = this.getAttribute( 'aria-expanded' ) === 'true';
        this.setAttribute( 'aria-expanded', ! expanded );
    });
});

Common Mistakes

Hardcoding menu markup: Avoid writing menu HTML directly in templates. Always use wp_nav_menu() or the Navigation block so administrators can manage content without code.

Forgetting fallback callbacks: If you don't set a fallback_cb, an unassigned menu location leaves the page with no navigation. Always provide a sensible fallback.

Ignoring escaping in walkers: Custom walkers often output user-provided data. Use esc_url() for URLs, esc_attr() for attributes, and wp_kses_post() for rich text to prevent XSS vulnerabilities.

Over-nesting menus: Deep menu hierarchies (depth > 4) become difficult to manage and often confusing for users. Keep nesting shallow and consider breadcrumbs for deep navigation.

Neglecting mobile navigation: Large menus that work on desktop become unwieldy on mobile. Use mobile menus, hamburger toggles, or the Navigation block's mobile collapse feature.

Missing keyboard navigation: Test your menus with only a keyboard. All interactive elements should be focusable and usable without a mouse.

Key Takeaways

  • In classic themes, use register_nav_menus() to declare menu locations, then render them with wp_nav_menu() in templates.
  • Block themes use the Navigation block in the Site Editor, eliminating the need for programmatic menu registration in most cases.
  • Custom walkers let you override default menu markup when your design system requires non-standard HTML structure.
  • Always escape output in walkers and avoid hardcoding menu markup to keep your theme maintainable and secure.
  • Accessible menus require semantic markup, skip links, current page indicators, and proper keyboard navigation support.
  • Provide fallback callbacks to prevent broken navigation if a menu isn't assigned.
  • Test menus across devices and with assistive technologies—accessibility isn't optional.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles