Custom Category Accordion

It is surprisingly hard to find a plugin that just outputs a simple accordion of something such as Product Categories. So much so that we ended up custom building one with ChatGPT o4 Mini – High to use for Impco. It’s streamlined enough that we can easily reuse it.

The entire thing is built with php and the javascript inlined directly into a shotcode. It will also use only 1 query if there are multiple instances of the accordion on a page, so it’s also quite optimised.

Please note though, that it is currently setup specifically for impco – ie. the code below will make the accordion go to a depth of 3 (Parent, Subcategory, sub-subcategory) and it will specifically be for Product Categories.

In order to reuse this in other projects, it needs to be modified to account for anything inside that project (i.e. the depth.) Just use ChatGPT o4 Mini – High for this, it is smart enough that it won’t try to reinvent the wheel if you ask for a basic change.

				
					/**
 * Returns all product categories grouped by parent ID.
 * Uses a static variable so that the query only runs once per page load.
 *
 * @return array Associative array where keys are parent IDs and values are arrays of term objects.
 */
function get_all_product_category_groups() {
    static $groups = null;
    if ( $groups === null ) {
        $terms = get_terms( array(
            'taxonomy'   => 'product_cat',
            'hide_empty' => false,
        ) );
        $groups = array();
        if ( ! is_wp_error( $terms ) ) {
            foreach ( $terms as $term ) {
                $groups[ $term->parent ][] = $term;
            }
        }
    }
    return $groups;
}

/**
 * Recursively builds the product category list using the pre-cached groups.
 *
 * @param int   $parent    The parent term ID.
 * @param int   $level     The current depth level (1 = parent, 2 = subcategory, 3 = sub‑subcategory).
 * @param array $active_ids Array of active term IDs (active category and its ancestors).
 * @param int   $active_cat The active category term ID.
 *
 * @return string HTML markup for the list.
 */
function build_product_category_list( $parent = 0, $level = 1, $active_ids = array(), $active_cat = 0 ) {
    if ( $level > 3 ) {
        return '';
    }
    
    $groups = get_all_product_category_groups();
    if ( empty( $groups[ $parent ] ) ) {
        return '';
    }
    
    // For non-root lists: if the parent is not in the active chain, add the "collapsed" class.
    $ul_class = 'accordion-list';
    if ( $parent != 0 && ! in_array( $parent, $active_ids ) ) {
        $ul_class .= ' collapsed';
    }
    
    $html = '<ul class="' . esc_attr( $ul_class ) . '">';
    foreach ( $groups[ $parent ] as $cat ) {
        if ( $cat->name === "Uncategorised" ) {
            continue;
        }
        // Determine classes based on depth.
        switch ( $level ) {
            case 1:
                $link_class     = 'parent-category';
                $li_extra_class = ' parent-item';
                break;
            case 2:
                $link_class     = 'subcategory';
                $li_extra_class = ' subcategory-item';
                break;
            case 3:
                $link_class     = 'sub-subcategory';
                $li_extra_class = ' sub-subcategory-item';
                break;
            default:
                $link_class     = '';
                $li_extra_class = '';
        }
        
        $html .= '<li class="accordion-item' . esc_attr( $li_extra_class ) . '">';
        
        // If this is the active category, add the "active-category" class on the title container.
        $active_class  = ( $cat->term_id == $active_cat ) ? ' active-category' : '';
        // Mark the title container as expanded if it's in the active chain.
        $expanded_attr = in_array( $cat->term_id, $active_ids ) ? ' data-expanded="true"' : '';
        $html        .= '<span class="product-category-title' . esc_attr( $active_class ) . '"' . $expanded_attr . '>';
        $html        .= '<a href="' . esc_url( get_term_link( $cat, "product_cat" ) ) . '" class="' . esc_attr( $link_class ) . '">' . esc_html( $cat->name ) . '</a>';
        
        // Only output the toggle arrow if there are child categories.
        $child_html = build_product_category_list( $cat->term_id, $level + 1, $active_ids, $active_cat );
        if ( $child_html ) {
            // Set the icon based on whether the category is in the active chain.
            $icon_class = in_array( $cat->term_id, $active_ids ) ? 'fa-chevron-up' : 'fa-chevron-down';
            $html      .= '<span class="toggle-arrow"><i class="fa ' . esc_attr( $icon_class ) . '"></i></span>';
        }
        $html .= '</span>';
        
        // Append nested children.
        if ( $child_html ) {
            $html .= $child_html;
        }
        
        $html .= '</li>';
    }
    $html .= '</ul>';
    
    return $html;
}

/**
 * Shortcode callback to output the product category accordion.
 * Now uses a unique container ID so that multiple instances do not conflict.
 *
 * @return string HTML markup for the accordion.
 */
function product_category_accordion_shortcode() {
    // Track active category if on a product category archive.
    $active_cat = 0;
    $active_ids = array();
    if ( is_product_category() ) {
        $current_term = get_queried_object();
        $active_cat   = $current_term->term_id;
        $ancestors    = get_ancestors( $active_cat, 'product_cat' );
        $active_ids   = array_merge( array( $active_cat ), $ancestors );
    }

    // Generate a unique ID for this accordion instance.
    static $shortcode_counter = 0;
    $shortcode_counter++;
    $unique_id = 'accordion-' . $shortcode_counter . '-' . uniqid();

    $output  = '<div class="category-accordion" id="' . esc_attr( $unique_id ) . '">';
    $output .= build_product_category_list( 0, 1, $active_ids, $active_cat );
    $output .= '</div>';
    
    // Inline JavaScript scoped to this instance.
    $output .= '
    <script type="text/javascript">
    (function(){
        var accordion = document.getElementById("' . esc_js( $unique_id ) . '");
        if (!accordion) return;
        
        function toggleChildList(childList, icon) {
            if(childList.classList.contains("collapsed")) {
                childList.classList.remove("collapsed");
                if(icon){
                    icon.classList.remove("fa-chevron-down");
                    icon.classList.add("fa-chevron-up");
                }
            } else {
                childList.classList.add("collapsed");
                if(icon){
                    icon.classList.remove("fa-chevron-up");
                    icon.classList.add("fa-chevron-down");
                }
            }
        }
        
        var titleContainers = accordion.querySelectorAll(".product-category-title");
        titleContainers.forEach(function(container) {
            var li = container.closest("li.accordion-item");
            var childUl = li.querySelector("ul.accordion-list");
            if(childUl){
                container.addEventListener("click", function(e) {
                    var link = container.querySelector("a");
                    if(e.target.closest("a")) {
                        if(!container.dataset.expanded){
                            e.preventDefault();
                            if(childUl.classList.contains("collapsed")){
                                toggleChildList(childUl, container.querySelector("i"));
                            }
                            container.dataset.expanded = "true";
                        }
                        return;
                    } else {
                        toggleChildList(childUl, container.querySelector("i"));
                        if(!childUl.classList.contains("collapsed")){
                            container.dataset.expanded = "true";
                        }
                    }
                });
            }
        });
    })();
    </script>
    ';
    
    return $output;
}
add_shortcode('product_category_accordion', 'product_category_accordion_shortcode');

				
			

See the CSS here to make this thing work properly:

It’s required because the JavaScript is mainly just adding and removing class names. Also note that the CSS below is very specific for Impco, but we can easily modify it.

				
					.category-accordion {
    width: 100%;
}

/* Base styling for the accordion list with transition for expand/collapse */
.accordion-list {
    list-style: none;
    margin: 0;
    padding: 0;
    width: 100%;
    overflow: hidden;
    max-height: 1200px; /* Expanded state: adjust this value if necessary */
    transition: max-height 0.4s ease-in-out;
}

/* Collapsed nested lists are hidden using animated max-height */
.accordion-list.collapsed {
    max-height: 0;
}

/* Each accordion item stacks vertically */
.accordion-item {
    display: flex;
    flex-direction: column;
    width: 100%;
}

/* The product-category-title is a flex container that lays out its children in a row */
.product-category-title {
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    padding: 8px 8px;
    cursor: pointer;
}

.subcategory-item .product-category-title,
.sub-subcategory-item .product-category-title {
    padding: 8px 8px 8px 16px;
}

/* The link retains its natural size */
.product-category-title a {
    text-decoration: none;
    transition: color 0.3s ease;
}

/* Toggle arrow styling */
.toggle-arrow {
    display: flex;
    align-items: center;
    margin-left: 0.5em;
}
.toggle-arrow i {
    font-size: 16px;
}

/* Figma colors for each category level */
.product-category-title a.parent-category {
    color: #0A0A0A;
    font-size: 18px;
}
.product-category-title a.subcategory {
    color: #194D61;
    font-size: 16px;
}
.product-category-title a.sub-subcategory {
    color: #555;
    font-size: 16px;
}

/* Hover states for each category level (fill in your chosen colors) */
.product-category-title a.parent-category:hover {
    /* e.g. color: #333; */
}

.product-category-title a.subcategory:hover {
    /* e.g. color: #245e79; */
}

.product-category-title a.sub-subcategory:hover {
    /* e.g. color: #aaaaaa; */
}

/* Active category styling */
.product-category-title.active-category {
    background: #194D61;
    border-radius: 6px;
}

.product-category-title.active-category a,
.product-category-title.active-category .toggle-arrow i {
    color: white;
}

.parent-item,
.subcategory-item {
    margin-bottom: 5px; 
}


				
			

This is actually quite a robust accordion and has the following features:

  • Clicking the arrow indicator will always control the accordion.
  • Clicking the category name directly will only act as an ordinary link if it has been expanded at least once.
  • Navigating to a product’s archive will result in the accordion loading expanded on the correct category.

As a side note – depending on the project we, may already have something else querying all the categories with some custom code (such as with Renttech) we should definitely optimize for that when we can – we don’t need to query the same thing each time we need to access it. It makes more sense to query it once and access the query multiple times rather than query multiple times.