One of my shortcodes renders a row of cards inside a display: grid container: three columns, clean gap, one card per cell. In the editor preview everything looked correct. The moment the page went live, the grid exploded. Some cards suddenly shrank into skinny columns, stray empty gaps appeared in the middle of the row, and items wrapped into positions that made no sense. Same template, same content, completely different result.
My first reflex was to blame the CSS. I checked grid-template-columns, checked the media queries, checked whether another stylesheet was overriding mine. All clean. Only when I opened DevTools and inspected the grid container did the culprit show itself. Scattered between my cards were empty <p> tags and rogue <br> tags:
<div class="card-grid">
<p></p>
<div class="card">...</div>
<p><br /></p>
<div class="card">...</div>
<p></p>
<div class="card">...</div>
</div>I never wrote those tags. They were not in the template, not in the editor. They simply appeared in the final HTML the browser received.
Why this happens
The culprit is wpautop, a core WordPress filter that runs over the_content. Its job sounds harmless: turn blank lines into <p>...</p> paragraphs and single newlines into <br />, so writers never have to type paragraph tags by hand. For prose, that is useful. The problem is that wpautop cannot tell prose from markup. It processes the entire content string, including the area where my shortcode's output ends up.
My shortcode template emitted markup separated by newlines between tags — a perfectly normal habit that keeps the source readable. To wpautop, those newlines looked like paragraph breaks, so it dutifully wrapped the fragments in <p> and slipped <br /> tags in between.
In an ordinary layout, an empty <p> adds a weird margin at worst. Inside display: grid, the consequences are far more brutal: every direct child of a grid container is a grid item. Every one of those injected empty <p> tags became a real grid item, a phantom cell occupying a track and shifting every card after it. That is why the cards shrank and the wrapping went haywire: the grid was counting children I never created. It is also why the editor preview looked fine — the preview render path does not go through the same the_content filter chain as the live page.
The fix
I fixed it in two layers.
Layer one: stop giving wpautop anything to guess about. I rewrote the shortcode callback to build the markup as one string, with no stray newlines between tags:
function render_card_grid($atts, $content = null) {
$cards = get_card_items();
$html = '<div class="card-grid">';
foreach ($cards as $card) {
$html .= '<div class="card">';
$html .= '<h3>' . esc_html($card['title']) . '</h3>';
$html .= '<p>' . esc_html($card['excerpt']) . '</p>';
$html .= '</div>';
}
$html .= '</div>';
return $html;
}
add_shortcode('card_grid', 'render_card_grid');For the enclosing variant of the shortcode, the inner content has already been processed by the time it reaches the callback. So I clean the artifacts out before using it:
$content = shortcode_unautop($content);
$content = preg_replace('#</?p>|<br\s*/?>#', '', $content);shortcode_unautop() reverses the paragraph wrapping around shortcode tags, and the preg_replace sweeps up any <p> and <br> that still slipped through. One caveat: stripping this aggressively is only appropriate for areas that are pure layout markup.
Layer two is the opposite move. Wherever paragraphs are actually wanted — the excerpt text inside each card, for example — I emit explicit <p> tags myself in the template, as in the snippet above. When the markup is explicit and tight, wpautop has nothing left to guess about.
There is one more classic trick worth installing: filter order. By default wpautop runs at priority 10 on the_content, while do_shortcode runs at 11. That means wpautop processes the content before shortcodes render, while the raw shortcode tags and the newlines around them are still exposed. Push it back:
remove_filter('the_content', 'wpautop');
add_filter('the_content', 'wpautop', 12);At priority 12, wpautop only runs after do_shortcode has finished. The shortcode output, now compact block-level markup, gets left alone, and wpautop only formats the actual prose around it. With both layers in place, the grid snapped back to normal: three tidy columns, no phantom cells.
The takeaway
If a grid or flex layout has mystery children, do not start with the CSS. Inspect the live DOM and look for injected <p> and <br> tags; a grid item count that does not match your template almost always means there is a child you never created. Remember that wpautop treats your shortcode output as prose unless you control the whitespace — newlines between tags are an open invitation for phantom paragraphs. And explicit markup beats implicit paragraphing every time: where you want a paragraph, write the <p> yourself. Since this incident, whenever a layout counts more children than I rendered, my first question is no longer about CSS. It is: who is editing my HTML mid-flight?
