Dynamic Posts Side Menu with Expandable and Collapsible Accordion Submenus in WordPress

This Pro tutorial provides the steps to show all the posts of a specified post type as menu items with accordion functionality for submenus.

This is especially useful for Pages and hierarchical CPTs that have parent > child posts.

While Bricks was used whilst developing the solution, this technique can be implemented in any WordPress site.

Step 1

Add the following in child theme‘s functions.php (w/o the opening PHP tag) or a code snippets plugin:

<?php

/**
 * Outputs a side menu with posts from a specific post type.
 *
 * @param string $post_type The post type to display.
 * @return void
 */
function bl_output_side_menu( string $post_type ): void {
    // Set up query arguments
    $args = [
        'post_type'      => $post_type,
        'posts_per_page' => -1,
        'orderby'        => 'title',
        'order'          => 'ASC',
        'post_parent'    => 0,
    ];

    // Create a new WP_Query instance
    $query = new WP_Query( $args );

    // Check if there are any posts
    if ( $query->have_posts() ) {
        // Start the navigation markup
        echo '<nav class="side-menu" aria-label="' . esc_attr( $post_type ) . ' navigation">';
        echo '<ul>';
        
        // Loop through the posts
        while ( $query->have_posts() ) {
            $query->the_post();
            bl_output_menu_item( get_the_ID(), $post_type );
        }
        
        // Close the navigation markup
        echo '</ul>';
        echo '</nav>';
    }

    // Reset the global post data
    wp_reset_postdata();
}

/**
 * Outputs a single menu item and its children.
 *
 * @param int    $post_id   The ID of the post.
 * @param string $post_type The post type.
 * @return bool Whether this item or any of its children is the current post.
 */
function bl_output_menu_item( int $post_id, string $post_type ): bool {
    // Get child posts
    $children = get_children( [
        'post_parent' => $post_id,
        'post_type'   => $post_type,
        'orderby'     => 'title',
        'order'       => 'ASC',
    ] );

    $has_children = ! empty( $children );
    $current_post_id = get_queried_object_id();
    $is_current = ( $current_post_id === $post_id );
    
    // Set up CSS classes for the menu item
    $classes = [ 'menu-item' ];
    if ( $is_current ) {
        $classes[] = 'current';
    }
    if ( $has_children ) {
        $classes[] = 'has-children';
    }

    $is_current_or_ancestor = $is_current;

    // Start the menu item markup
    echo '<li class="' . esc_attr( implode( ' ', $classes ) ) . '">';

    // Output different markup based on whether the item has children or is current
    if ( $has_children ) {
        $expanded = 'false';
        echo '<button aria-expanded="' . $expanded . '" class="toggle-submenu">';
        echo '<span class="screen-reader-text">Toggle submenu for </span>';
        echo esc_html( get_the_title( $post_id ) );
        echo '<span class="toggle-icon" aria-hidden="true">+</span>';
        echo '</button>';
    } elseif ( $is_current ) {
        echo '<span class="current-item">' . esc_html( get_the_title( $post_id ) ) . '</span>';
    } else {
        echo '<a href="' . esc_url( get_permalink( $post_id ) ) . '" class="menu-link">';
        echo esc_html( get_the_title( $post_id ) );
        echo '</a>';
    }

    // If the item has children, output the submenu
    if ( $has_children ) {
        echo '<ul class="submenu" hidden>';
        foreach ( $children as $child ) {
            $child_is_current = bl_output_menu_item( $child->ID, $post_type );
            if ( $child_is_current ) {
                $is_current_or_ancestor = true;
            }
        }
        echo '</ul>';
    }

    // If the item is current or an ancestor and has children, add JavaScript to expand the submenu
    if ( $is_current_or_ancestor && $has_children ) {
        echo '<script>
            document.currentScript.previousElementSibling.hidden = false;
            document.currentScript.previousElementSibling.previousElementSibling.setAttribute("aria-expanded", "true");
            document.currentScript.previousElementSibling.previousElementSibling.querySelector(".toggle-icon").textContent = "–";
        </script>';
    }

    // Close the menu item markup
    echo '</li>';

    return $is_current_or_ancestor;
}

Step 2

Add this in child theme’s style.css or in your single template:

.side-menu {
    text-transform: uppercase;
    letter-spacing: 0.02em;
    box-shadow: 0 0 5px #eaeaeb;
}

.side-menu ul {
    margin: 0;
    padding-left: 0;
    list-style-type: none;
}

.side-menu .submenu {
    display: none;
    width: 100%;
    background-color: #fff;
}

.side-menu .toggle-submenu[aria-expanded="true"] + .submenu {
    display: block;
}

.side-menu .menu-item {
    font-size: var(--text-s);
    line-height: 1;
    background-color: #fff;
    border-bottom: 1px solid #eaeaeb;
}

.side-menu > ul > .menu-item:last-child {
    border-bottom: none;
}

.side-menu .menu-link,
.side-menu .current-item,
.side-menu .toggle-submenu {
    display: block;
    width: 100%;
    padding: 20px 25px;
    color: #242832;
    opacity: 0.6;
}

.side-menu .toggle-submenu {
    display: flex;
    justify-content: space-between;
    align-items: center;
    text-align: left;
    line-height: 1;
    background: none;
    border: none;
    cursor: pointer;
    text-transform: inherit;
}

.side-menu .toggle-icon {
    margin-left: auto;
    font-weight: 700;
}

.side-menu .menu-link:hover,
.side-menu .toggle-submenu:hover {
    opacity: 1;
}

.side-menu .current > .menu-link,
.side-menu .current > .current-item,
.side-menu .current > .toggle-submenu {
    padding-left: 22px;
    opacity: 1;
    border-left: 3px solid black;
}

.side-menu .current-item {
    background-color: #fbfcfc;
}

.side-menu .submenu .menu-item {
    border-bottom: none;
}

.side-menu .submenu .menu-link,
.side-menu .submenu .toggle-submenu {
    padding-left: 50px;
}

.side-menu .submenu .current-item {
    padding-left: 47px;
}

.side-menu .submenu .submenu .menu-link,
.side-menu .submenu .toggle-submenu + .submenu .current-item {
    padding-left: 74px;
}

Step 3

Enqueue this JS to load on your single CPT pages or if you are using Bricks, paste this at Settings → Page Settings → Custom Code → Body (footer) scripts:

<script>
document.addEventListener('DOMContentLoaded', function() {
    const toggleButtons = document.querySelectorAll('.side-menu .toggle-submenu');

    toggleButtons.forEach(button => {
        button.addEventListener('click', function() {
            const expanded = this.getAttribute('aria-expanded') === 'true' || false;
            this.setAttribute('aria-expanded', !expanded);
            
            const submenu = this.nextElementSibling;
            submenu.hidden = expanded;

            const icon = this.querySelector('.toggle-icon');
            icon.textContent = expanded ? '+' : '–';
        });
    });
});
</script>

Step 4

Add this code in your single template:

<?php bl_output_side_menu( 'page' ); ?>

Replace page with your desired post type name.