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.