Customise WordPress body_class and post_class with Conditional PHP Filters

WordPress automatically generates a rich set of CSS class names for the <body> and <article> elements of every page. On a single post for a logged-in administrator, body_class() might output thirty or more classes — single single-post postid-42 logged-in admin-bar, plus classes for the current theme and any active plugins. These classes are powerful hooks for CSS targeting, but the default set is not always what your design needs. You might want to add a custom layout class based on an ACF field, remove the verbose post-ID class for cleaner HTML, append a class when a page has a featured image, or propagate a parent page slug to child pages for section-level theming. WordPress provides two filters for this: body_class modifies the array passed to body_class(), and post_class modifies the array passed to post_class(). Both filters receive the current classes array and the query object, giving you full context to make conditional additions and removals.

Problem: You need to add custom CSS classes to the body or article element based on ACF fields, page hierarchy, or user state — and remove noisy default classes like postid-42 that leak internal IDs into the HTML source.

Solution: Filter the body_class and post_class arrays. Add conditional classes with context-aware logic; remove unwanted defaults by unsetting array values.

<?php
add_filter( 'body_class', 'custom_body_classes' );

function custom_body_classes( $classes ) {
    // Add layout class from ACF field (e.g. 'layout-sidebar', 'layout-full')
    if ( is_singular() ) {
        $layout = get_field( 'page_layout' );
        if ( $layout ) {
            $classes[] = sanitize_html_class( 'layout-' . $layout );
        }
    }

    // Add parent page slug as section class on child pages
    if ( is_page() ) {
        $ancestors = get_post_ancestors( get_the_ID() );
        if ( ! empty( $ancestors ) ) {
            $top_ancestor = end( $ancestors );
            $classes[]    = sanitize_html_class( 'section-' . get_post_field( 'post_name', $top_ancestor ) );
        }
    }

    // Add class when featured image is present on a single post
    if ( is_singular( 'post' ) && has_post_thumbnail() ) {
        $classes[] = 'has-featured-image';
    }

    // Remove post ID classes to avoid leaking internal IDs (e.g. 'postid-42', 'page-id-7')
    $classes = array_filter( $classes, function ( $class ) {
        return ! preg_match( '/^(postid|page-id|term-id)-\d+$/', $class );
    } );

    return $classes;
}

add_filter( 'post_class', 'custom_post_classes', 10, 3 );

function custom_post_classes( $classes, $class, $post_id ) {
    // Add 'featured' class when a post has a specific tag
    if ( has_tag( 'featured', $post_id ) ) {
        $classes[] = 'post--featured';
    }

    // Add reading-time class bucket (short / medium / long)
    $content    = get_post_field( 'post_content', $post_id );
    $word_count = str_word_count( strip_tags( $content ) );
    if ( $word_count < 300 ) {
        $classes[] = 'read-time--short';
    } elseif ( $word_count < 800 ) {
        $classes[] = 'read-time--medium';
    } else {
        $classes[] = 'read-time--long';
    }

    return $classes;
}

NOTE: Always pass class values through sanitize_html_class() before adding them to the array. The function strips any character that is not alphanumeric, a hyphen, or an underscore — preventing attribute injection if the class value originates from user input or a database field. Without sanitisation, a field containing a double-quote or a space can break the HTML attribute and open an XSS vector.