WordPress wp_list_comments(): Build a Custom Comment Card Template with Reply Link and Avatar

WordPress’s built-in comment rendering system is controlled by wp_list_comments(), which outputs a nested list of comments for the current post. By default, it uses an internal callback that generates the comment HTML. Replacing this with a custom callback via the 'callback' argument — and optionally an 'end-callback' for the closing element — gives full control over the comment HTML structure: adding avatars, formatted dates, author meta, reply links, custom data attributes, and microdata. A paired Walker_Comment subclass provides even more control over how nested comment threads are structured. Most themes use the callback approach; plugins that need to dramatically restructure comment threading use the Walker approach. Understanding both mechanisms is important for building design-system-compliant comment sections without JavaScript frameworks.

Problem: A theme needs comments displayed as cards with a round avatar, author name linking to their website, a relative timestamp ("3 days ago"), an edit link for admins, and a reply link — all without the default <li class="comment"> structure that the built-in callback produces.

Solution: Pass a custom 'callback' function to wp_list_comments(). The callback receives the comment object, an array of args, and the nesting depth. Handle the opening element; the 'end-callback' handles the closing element.

<?php
// ── Custom comment callback ────────────────────────────────────────────
// Called once per comment; outputs the OPENING element
function my_comment_callback( WP_Comment $comment, array $args, int $depth ): void {
    // Compute relative time
    $posted    = strtotime( $comment->comment_date );
    $diff      = human_time_diff( $posted, current_time( 'timestamp' ) );
    $time_text = sprintf( __( '%s ago', 'textdomain' ), $diff );

    $avatar    = get_avatar( $comment, 48, '', '', [ 'class' => 'comment-avatar' ] );
    $author    = get_comment_author( $comment );
    $author_url = get_comment_author_url( $comment );

    $reply_link = get_comment_reply_link( array_merge( $args, [
        'depth'  => $depth,
        'before' => '',
        'after'  => '',
    ] ), $comment );

    $is_approved = '1' === $comment->comment_approved;
    ?>
    <div id="comment-<?php comment_ID(); ?>"
         class="comment-card<?php echo $depth > 1 ? ' is-reply' : ''; ?>"
         data-depth="<?php echo esc_attr( $depth ); ?>">

        <div class="comment-card__avatar">
            <?php echo $avatar; ?>
        </div>

        <div class="comment-card__body">
            <header class="comment-card__meta">
                <span class="comment-card__author">
                    <?php if ( $author_url ) : ?>
                        <a href="<?php echo esc_url( $author_url ); ?>" rel="nofollow"><?php echo esc_html( $author ); ?></a>
                    <?php else : ?>
                        <?php echo esc_html( $author ); ?>
                    <?php endif; ?>
                </span>
                <time class="comment-card__time" datetime="<?php echo esc_attr( $comment->comment_date ); ?>">
                    <?php echo esc_html( $time_text ); ?>
                </time>
            </header>

            <?php if ( ! $is_approved ) : ?>
                <p class="comment-pending"><em><?php esc_html_e( 'Your comment is awaiting moderation.', 'textdomain' ); ?></em></p>
            <?php else : ?>
                <div class="comment-card__content">
                    <?php comment_text(); ?>
                </div>
            <?php endif; ?>

            <footer class="comment-card__actions">
                <?php echo $reply_link; ?>
                <?php edit_comment_link( __( 'Edit', 'textdomain' ), '', '' ); ?>
            </footer>
        </div>
    <?php
    // NOTE: do NOT close the wrapper here — end-callback does it
}

function my_comment_end_callback( WP_Comment $comment, array $args, int $depth ): void {
    echo '</div>'; // closes comment-card__body and comment-card divs
}

// ── Render the comments section ───────────────────────────────────────
function render_comments(): void {
    if ( ! have_comments() && ! comments_open() ) return;
    ?>
    <section id="comments" class="comments-section">
        <h2><?php comments_number( 'No comments', '1 comment', '% comments' ); ?></h2>

        <ol class="comment-list">
            <?php wp_list_comments( [
                'callback'     => 'my_comment_callback',
                'end-callback' => 'my_comment_end_callback',
                'type'         => 'comment',   // 'comment', 'pingback', 'trackback', 'all'
                'style'        => 'ol',        // wrapping list element type
                'max_depth'    => 3,           // max thread nesting depth
                'avatar_size'  => 48,
                'per_page'     => 20,
                'page'         => get_query_var( 'cpage', 1 ),
            ] ); ?>
        </ol>

        <?php the_comments_pagination(); ?>
    </section>
    <?php
}

NOTE: The callback outputs the opening wrapper for each comment; the end-callback outputs the closing tag. If you use a single wrapper element (one <div> per comment), the end-callback should close it. WordPress handles the nesting of child comment lists between the opening and closing callbacks automatically — you do not need to manage thread nesting yourself. The 'style' argument ('ol' or 'ul' or 'div') determines the container element for the entire comment list passed to wp_list_comments(), not the per-comment element — the per-comment element is fully controlled by your callback. Use edit_comment_link() instead of checking current_user_can('edit_comment', $comment_id) manually — the function already does the capability check internally.