Where Block Themes Break a Plugin's Front-End Output
A block theme generates the CSS your plugin output sits inside. Here are the three collisions, the tokens to inherit, and what you cannot design for.
Priya Raghunathan · @praghunathan
Frontend and accessibility
- Topic
- FE
- Section
- Frontend & Accessibility
- Published
- 11 JUN 2026
- Reading time
- 7 MIN
- Words
- 1881W
A plugin renders a status badge with a green background. On the developer's own test site, running a classic theme, it looks correct. Six months later a customer on a block theme sends a screenshot: the badge is a pale wash that fails contrast against the surrounding card, and the label is nearly unreadable. Nothing in the plugin changed. The theme did.
That is the shape of this problem. A block theme does more than style its own templates. It publishes a set of design decisions into every page, including the pages where your plugin's markup lives, so your plugin stylesheet is now one input among several rather than the last word.
Three collisions account for most of the reports I read, and each needs a different fix. They share one cause. The plugin assumes it owns the presentation of the markup it emits, and on a classic theme that assumption usually held.
theme.json publishes design decisions the whole page can read
In a classic theme a plugin inherits almost nothing. Your stylesheet loads after the theme's, the cascade resolves in your favour, and the only surprise is a theme author who wrote a selector more specific than yours.
A block theme inverts that. The theme.json file declares the palette, the spacing
scale, the typography scale and the layout widths. WordPress reads the file and prints CSS custom
properties into the page, which is why a theme and three plugins can agree on what "large" means
without sharing a stylesheet.
The properties arrive in families. Palette entries become
--wp--preset--color--<slug>. Spacing steps become
--wp--preset--spacing--<slug>. Font sizes become
--wp--preset--font-size--<slug>. Layout widths become
--wp--content-size and --wp--wide-size. The slugs come from the theme, not
from WordPress, so --wp--preset--color--primary exists only if the theme declared a colour
with that slug.
{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 3,
"settings": {
"color": {
"palette": [
{ "slug": "base", "color": "#ffffff", "name": "Base" },
{ "slug": "contrast", "color": "#14161a", "name": "Contrast" },
{ "slug": "accent", "color": "#1f6f5c", "name": "Accent" }
]
},
"spacing": {
"units": [ "px", "rem", "%" ],
"spacingSizes": [
{ "slug": "40", "size": "1rem", "name": "Small" },
{ "slug": "60", "size": "2rem", "name": "Medium" }
]
},
"layout": { "contentSize": "44rem", "wideSize": "64rem" }
}
}
The generated CSS is part of the document rather than part of a plugin, so global styles are printed into the page and you cannot enqueue your way in front of them. And because the values are custom properties rather than build-time variables, they resolve in the browser, so your plugin reads what the theme decided instead of guessing at it.
Scope
A classic theme can also ship theme.json and print the same properties, so none of
this belongs to block themes exclusively. The practical difference is that a block theme depends on
those properties, so they are present and consistent enough to target.
Hard-coded colours lose to a palette the plugin never saw
The first collision is the most common one. A plugin stylesheet sets
background-color: #1f6f5c on a success badge, chosen once against a light background. On
a theme with a dark palette that badge becomes a saturated block dragging the eye away from the
content. On a theme built for high contrast it may sit below the ratio the theme maintains everywhere
else. The plugin did not get worse. It stopped being the only thing on the page with an opinion about
colour.
Replacing every colour with a settings field is not the answer. Most sites never open plugin settings, and a badge with eleven configuration options has not become more portable, only more annoying. Inheritance with a fallback is the answer.
/* Plugin stylesheet: read the theme token, fall back to a shippable default. */
.wpsifero-status-badge {
background-color: var(--wp--preset--color--accent, #1f6f5c);
color: var(--wp--preset--color--base, #ffffff);
padding: var(--wp--preset--spacing--40, 0.5rem)
var(--wp--preset--spacing--60, 1rem);
border-radius: 2px;
}
.wpsifero-status-badge:focus-visible {
outline: 2px solid var(--wp--preset--color--contrast, #14161a);
outline-offset: 2px;
}
The fallback is the second argument to var(), and it applies only when the property is
unset, which is exactly the classic theme case. The hex value now appears once per declaration, so
changing it is a one-line edit rather than a search through compiled CSS. And the slug is a
dependency: if you read --wp--preset--color--accent while the theme named its
accent something else, the fallback quietly applies and the badge looks identical on every theme,
which is the bug you set out to fix.
So check what a theme actually publishes before you blame your own cascade. On a stock WordPress 6.7 install with Twenty Twenty-Five active, this prints the preset properties present in the page head:
curl -s https://example.test/ | grep -o -- '--wp--preset--[a-z-]*' | sort -u
Whatever that command prints is the vocabulary you are allowed to speak. Anything outside it needs a fallback you are willing to ship.
A block style that is not declared never reaches the editor
The second collision only appears where the editor is involved, which is why plugins written for
shortcodes carry it for years. The plugin registers a block, ships a stylesheet, enqueues it on the
front end through wp_enqueue_scripts, and the editor preview looks nothing like the
published page.
That is not a caching problem. The editor renders blocks inside an iframe using the styles each block declared to it. A blanket front-end enqueue declares nothing, so the editor has no reason to load the file.
wp_enqueue_block_style() is the registration that fixes it. You name the block, you
name the stylesheet, and WordPress loads the stylesheet when that block is rendered, in the editor
and on the front end, without loading it on pages that contain no such block.
function wpsifero_register_block_styles() {
wp_enqueue_block_style(
'wpsifero/status-badge',
array(
'handle' => 'wpsifero-status-badge',
'src' => plugins_url( 'assets/status-badge.css', __FILE__ ),
'path' => __DIR__ . '/assets/status-badge.css',
'ver' => '1.4.0',
)
);
}
add_action( 'init', 'wpsifero_register_block_styles' );
The plugin no longer asks whether this is the front end. It answers whether the page contains its block, and WordPress decides where the file goes. The same call covers the editor canvas, so the preview and the published page converge instead of drifting apart over releases.
A couple of limits apply before you convert every enqueue. The stylesheet is attached by block name, so a plugin with a dozen blocks needs a dozen registrations, or one shared stylesheet registered against each of them. And when your block's markup also comes out of a shortcode, the shortcode path still needs its own enqueue, because a shortcode is not a block and nothing in this API knows about it.
A shortcode wrapper sits outside the width the theme set
The third collision is the quietest, because it usually looks like a slightly wide table rather
than a bug. A shortcode emits <div class="wpsifero-pricing-table">. On a classic
theme the content column has a width in the theme's stylesheet and the wrapper fits inside it. On a
block theme the width comes from the layout settings, and a constrained layout applies a maximum width
to the direct children of the content container. Your wrapper is one of those children, so it is
constrained. Its own children are not.
The visible result is a grid inside the wrapper that uses viewport units or a fixed pixel column
and overhangs the column the theme chose. The page rarely scrolls sideways, because something above
the wrapper has overflow set, so the last column is simply clipped. Support tickets about
that are almost never filed, which is why this one survives.
Read the same width the theme reads, with the same fallback discipline:
.wpsifero-pricing-table {
max-width: var(--wp--content-size, 44rem);
margin-inline: auto;
display: grid;
gap: var(--wp--preset--spacing--40, 0.75rem);
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
}
/* A wider variant, only where the theme exposes a wide width. */
.wpsifero-pricing-table--wide {
max-width: var(--wp--wide-size, var(--wp--content-size, 44rem));
}
That covers a plugin rendering one wrapper per shortcode. It does not cover a table whose natural
width exceeds any width the theme declares. A six-column comparison table on a 360 px viewport will
overflow whatever tokens you read, and the honest fix there is a scroll container around the table
rather than a clever max-width.
| Plugin output | Classic theme | Block theme |
|---|---|---|
| Badge with a hex background | Your colour wins; contrast is your problem | Theme palette sits alongside it; contrast can fail |
Block styled through wp_enqueue_scripts | Loads everywhere, editor preview is approximate | Editor iframe may not receive the file at all |
| Shortcode wrapper at full width | Fits the theme's content column | Constrained as a child; its own children are not |
| Form control with its own font stack | Inherits the theme's body font | Typography presets may target your elements directly |
You cannot design for every theme, so name the ones you test
There is no version of this advice that generalises to the whole theme directory. Block themes
differ in how much they declare, which slugs they choose, whether they print properties for colours a
visitor has not selected, and how aggressively their global styles target elements by tag rather than
by class. A minimal theme.json leaves you with preset properties for the default palette
and very little else, and that is a legitimate way for a theme to ship.
A short list and a repeated test work instead. Four themes has been enough for the plugins I look at: one default block theme kept at its defaults, one commercial theme with a heavy design system, one deliberately minimal block theme, and one classic theme as the fallback case. Run the plugin's screens on each after every release. The single test that finds the most is switching the palette to a dark variant and looking at anything that carries meaning by colour alone.
What keeps this from rotting is writing down what you depend on. A short section in the plugin readme listing each token, what it is used for and what happens when it is missing turns a mystery report into a five-minute check:
Tokens this plugin reads
--wp--preset--color--accent badge background, focus ring
--wp--preset--color--base badge text
--wp--preset--spacing--40 internal padding
--wp--content-size pricing wrapper max width
Missing token behaviour: falls back to the values in assets/css/plugin.css.
Then keep the fallbacks honest. A hex value that creeps back into a component during a bug fix will not be noticed on either of the light test themes, so fail the build rather than relying on review. On a plugin with roughly 900 lines of CSS, this check finishes in well under a second in CI:
if grep -rnE '#[0-9a-fA-F]{3,8}' assets/css/; then
echo "hard-coded colour in plugin CSS"
exit 1
fi
The check is crude and it will flag a comment that mentions a colour code, which is a small price for catching the one that matters.
What to do on Monday
- Grep the plugin stylesheet for hex values and separate the fallbacks from the leftovers.
- Replace each leftover with the preset property you actually want, keeping the current value as the fallback.
- Confirm the theme prints that property. If it does not, choose a different slug or accept the fallback on purpose.
- Move every block stylesheet off the blanket front-end enqueue and onto
wp_enqueue_block_style(), then check the editor preview before the front end. - Set wrapper widths from
--wp--content-sizeand add a scroll container for tables that cannot fit. - Write the token list into the readme, test on your four themes, and attach the screenshots to the release notes.
Priya Raghunathan · @praghunathan
Frontend and accessibility
Reviews interfaces for what happens when JavaScript fails, the keyboard is the only input, and the connection is slow. Focused on Core Web Vitals, semantic markup and progressive enhancement.
Continue