A category archive can be more useful than a reverse-chronological wall of posts. When the current category has meaningful children—Linux distributions, Android subsystems, cloud providers—showing those paths first gives the visitor a choice instead of making them scroll and guess.

Direct children and all descendants are different menus

  • Use parent => $term_id for one navigation level directly beneath the current category.

  • Use child_of => $term_id when a flat result containing grandchildren and deeper descendants is genuinely desired.

  • A recursive tree needs hierarchy-aware rendering; a flat alphabetical list can hide parent/child relationships.

  • hide_empty => true omits terms with no published objects according to WordPress term counts.

  • Choose the hierarchy based on the visitor’s task, not simply the number of terms available.

Classic theme: keep the query in a reusable function

inc/category-navigation.phpphp
<?php
/**
 * Renders direct child categories for the current category archive.
 */
function lynxbee_render_child_category_nav(): void {
    if ( ! is_category() ) {
        return;
    }
 
    $current_term = get_queried_object();
 
    if (
        ! $current_term instanceof WP_Term ||
        'category' !== $current_term->taxonomy
    ) {
        return;
    }
 
    $children = get_terms(
        array(
            'taxonomy'   => 'category',
            'parent'     => $current_term->term_id,
            'hide_empty' => true,
            'orderby'    => 'name',
            'order'      => 'ASC',
        )
    );
 
    if ( is_wp_error( $children ) || empty( $children ) ) {
        return;
    }
 
    echo '<nav class="child-category-nav" aria-labelledby="child-category-title">';
    echo '<h2 id="child-category-title">' .
        esc_html__( 'Explore this topic', 'your-theme' ) .
        '</h2><ul>';
 
    foreach ( $children as $child ) {
        $url = get_term_link( $child );
 
        if ( is_wp_error( $url ) ) {
            continue;
        }
 
        printf(
            '<li><a href="%1$s">%2$s</a></li>',
            esc_url( $url ),
            esc_html( $child->name )
        );
    }
 
    echo '</ul></nav>';
}

Every early return protects a real boundary

  • is_category() prevents the renderer from appearing on unrelated archives or singular pages.

  • get_queried_object() returns the archive subject; the loop’s global $post is a different object.

  • The WP_Term and taxonomy checks protect code reused in broader templates.

  • get_terms() can return WP_Error; checking only empty() is insufficient.

  • get_term_link() can also return an error and must be validated before escaping/output.

Load the helper and render it in category.php

functions.phpphp
<?php
require_once get_theme_file_path( '/inc/category-navigation.php' );

Use the active theme path intentionally

  • get_theme_file_path() resolves a file from the active theme and supports child-theme overrides.

  • require_once avoids duplicate function declarations.

  • Do not paste the same query into several templates; fixes and accessibility changes will drift.

  • Prefix global PHP functions to reduce collisions with themes and plugins.

category.phpphp
<?php get_header(); ?>
 
<main id="primary" class="site-main">
    <?php get_template_part( 'template-parts/archive/header' ); ?>
 
    <?php lynxbee_render_child_category_nav(); ?>
 
    <?php if ( have_posts() ) : ?>
        <?php while ( have_posts() ) : ?>
            <?php the_post(); ?>
            <?php get_template_part( 'template-parts/content', 'excerpt' ); ?>
        <?php endwhile; ?>
 
        <?php the_posts_pagination(); ?>
    <?php else : ?>
        <?php get_template_part( 'template-parts/content', 'none' ); ?>
    <?php endif; ?>
</main>
 
<?php get_footer(); ?>

Place taxonomy navigation outside the post loop

  • The child-category menu describes the archive, not an individual post.

  • Keeping it before the loop prevents repetition for every result.

  • The main query and pagination remain untouched.

  • A theme-specific template part preserves the existing visual system.

  • Edit a child theme or maintained custom theme so parent-theme updates do not overwrite the change.

Limit the feature to one selected parent archive

inc/category-navigation.phpphp
$featured_parent = get_category_by_slug( 'engineering' );
 
if (
    ! $featured_parent instanceof WP_Term ||
    $current_term->term_id !== $featured_parent->term_id
) {
    return;
}

A slug is clearer than a database-specific ID

  • Term IDs differ between development, staging, imports, and production.

  • A stable, controlled slug makes configuration portable and readable.

  • Verify the lookup result because a deleted/renamed category returns false.

  • If editors should configure the category, store a validated term ID in an option instead of editing PHP.

  • If the requirement includes every archive beneath the parent, use term_is_ancestor_of() rather than equality.

Show counts without corrupting accessible names

inc/category-navigation.php (inside the loop)php
printf(
    '<li><a href="%1$s"><span>%2$s</span> <span aria-hidden="true">(%3$s)</span><span class="screen-reader-text"> %4$s</span></a></li>',
    esc_url( $url ),
    esc_html( $child->name ),
    esc_html( number_format_i18n( $child->count ) ),
    esc_html( sprintf(
        _n( '%s post', '%s posts', $child->count, 'your-theme' ),
        number_format_i18n( $child->count )
    ) )
);

Counts are context, not decoration

  • number_format_i18n() formats numbers for the site locale.

  • _n() selects singular or plural translation based on the count.

  • The visual parentheses are hidden from assistive technology and replaced with readable text.

  • Term counts may include only directly assigned posts unless counts are padded/configured differently.

  • Do not make sparse categories look broken; omit counts when they do not help decisions.

Include empty categories only with a reason

  • hide_empty => true keeps navigation from leading to empty archives in ordinary editorial sites.

  • Use false for a curriculum, product taxonomy, or planned information architecture where empty destinations have purposeful content.

  • A custom taxonomy’s object types and term-count callbacks affect what “empty” means.

  • Cached/object-count changes may lag during imports or unusual integrations; recount terms when operational evidence shows stale counts.

  • If empty archive pages are indexable, ensure they offer unique value instead of thin search landing pages.

Custom hierarchical taxonomies

taxonomy-topic.phpphp
$current_term = get_queried_object();
 
if ( $current_term instanceof WP_Term && 'topic' === $current_term->taxonomy ) {
    $children = get_terms(
        array(
            'taxonomy'   => 'topic',
            'parent'     => $current_term->term_id,
            'hide_empty' => true,
        )
    );
}

Use the taxonomy registered by the application

  • The core category taxonomy name is category; custom taxonomy names are registration-specific.

  • A taxonomy must be hierarchical for parent/child navigation to make semantic sense.

  • Custom taxonomy classic templates follow taxonomy-{taxonomy}-{term}.php, then broader fallbacks.

  • Core category archives use the separate category template hierarchy.

  • Share rendering logic by accepting the validated taxonomy and term as parameters.

Block themes need a block-aware integration

  • Block themes resolve category templates from the database, child theme, or /templates/category*.html hierarchy.

  • A PHP call cannot simply be pasted into an HTML block template.

  • For a reusable feature, register a dynamic server-rendered block whose render callback performs the validated term query.

  • A shortcode block can bridge an existing site, but a purpose-built block gives editors clearer controls and better preview behavior.

  • Keep output escaping and archive-context validation inside the server render callback; block attributes are untrusted input.

A minimal dynamic-block render callback

plugin.phpphp
register_block_type(
    __DIR__ . '/build/child-category-navigation',
    array(
        'render_callback' => function (): string {
            if ( ! is_category() ) {
                return '';
            }
 
            ob_start();
            lynxbee_render_child_category_nav();
            return (string) ob_get_clean();
        },
    )
);

Server rendering keeps archives current

  • The block renders against the queried archive term on each page request/cache generation.

  • ob_start() captures the existing echo-based renderer; returning markup is the block API contract.

  • A production plugin should register on init, include block.json, translations, styles, and an editor preview/placeholder.

  • Full-page caches still need normal invalidation when terms change.

  • Do not cache one archive’s rendered HTML under a key shared by every category.

Performance and cache behavior

  • WordPress term queries participate in object caching; avoid premature custom transients for a small taxonomy.

  • Avoid N+1 queries such as fetching children separately for every term when one structured query can serve the page.

  • Large taxonomies may need pagination, curated navigation, or cached trees instead of rendering thousands of links.

  • Term creation, deletion, edits, and object assignment must invalidate any custom cache keys.

  • Measure database/object-cache behavior in the actual hosting stack before adding complexity.

Style the list without erasing semantics

style.csscss
.child-category-nav ul {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
  gap: 0.75rem;
  margin: 0;
  padding: 0;
  list-style: none;
}
 
.child-category-nav a {
  display: block;
  padding: 0.875rem 1rem;
  border: 1px solid currentColor;
  border-radius: 0.5rem;
}
 
.child-category-nav a:focus-visible {
  outline: 0.2rem solid currentColor;
  outline-offset: 0.2rem;
}
  • The <nav>, heading, list, and anchors remain intact for assistive technology.

  • auto-fit lets cards wrap without viewport-specific column counts.

  • Visible keyboard focus is essential; do not remove outlines without an equivalent.

  • Check long translations and category names at narrow widths.

  • Use theme design tokens/colors where available so contrast and dark mode stay consistent.

Troubleshooting

  • No children appear: confirm the current term actually has direct children and reconsider hide_empty.

  • Grandchildren are missing: parent intentionally returns one level; use a recursive hierarchy or child_of deliberately.

  • Wrong archive is detected: use get_queried_object(), not $post inside the loop.

  • Links print an error or break markup: handle WP_Error, then apply esc_url and esc_html.

  • Changes vanish after theme update: move customizations to a child theme, custom plugin, or supported hook/block.

  • Block template shows raw PHP: block HTML templates do not execute arbitrary PHP; use a dynamic block.

  • Counts look stale: inspect imports/object assignments, term recounting, persistent object cache, and full-page cache invalidation.

  • Every archive shows the menu: keep is_category, taxonomy, and selected-parent checks inside the renderer.

Verification checklist

  • Direct-child versus descendant behavior matches the information architecture.

  • Empty-term policy, ordering, count meaning, and selected parent are explicit.

  • Every query/link error path is handled and every dynamic value is context-escaped.

  • The navigation has a label, semantic list, visible focus, responsive layout, and meaningful link names.

  • Classic or block-theme placement follows the active theme architecture and survives updates.

  • Large-taxonomy performance, page/object caches, multilingual terms, and SEO treatment are tested.

Official WordPress references