Design systems in themes
Per-Block CSS and Responsive Visibility
The four places block CSS can live, the specificity WordPress generates for each, and the honest options for hiding a block at a breakpoint.
You need a rule. It is one declaration, it belongs to one block, and there are four places it could go. Put it in the wrong one and it works right up until somebody changes a different file, at which point it either stops applying or starts applying everywhere. The difference between the four is not style preference. Each one produces a different selector, and the selector decides what beats what.
Four homes for block CSS, and what each one compiles to
Start with the mechanism, because once you know the selector each option produces, the choice mostly makes itself.
| Where the rule lives | Selector WordPress prints | Specificity |
|---|---|---|
theme.json, styles.blocks.<block> | :root :where(p) | 0-1-0 |
theme.json, styles.blocks.<block>.css | :root :where(p) | 0-1-0 |
The theme's style.css | whatever you wrote | yours |
A block stylesheet via block.json | whatever you wrote | yours |
| Per-block custom CSS from the editor | :root :where(.wp-custom-css-0f7af4e9) | 0-1-0 |
The :where() is the whole trick. It wraps the selector so it contributes
zero specificity, and the :root in front supplies one class worth, for 0-1-0.
That beats a bare element selector like p (0-0-1) and loses to
.wp-block-post-title a (0-2-1). It also means your theme rules win against
core's own block CSS, which is mostly element selectors, without any
!important.
Semantic styles and custom CSS land on the same specificity, which surprises people who expect nested CSS to be more specific. It is not. The nesting only builds the selector.
{
"styles": {
"blocks": {
"core/paragraph": {
"css": "text-wrap: pretty; & > a { text-decoration-thickness: 1px; }"
}
}
}
}
:root :where(p) { text-wrap: pretty; }
:root :where(p > a) { text-decoration-thickness: 1px; }
Note what happened to core/paragraph. You wrote a block name and got an
element selector, because that is the block's registered selector. For a block whose
selector is a class you get the class: core/group with a colour and padding
produces
:root :where(.wp-block-group){background-color: var(--wp--preset--color--base);padding-top: var(--wp--preset--spacing--50);}.
Preset references written as var:preset|color|base arrive as
var(--wp--preset--color--base).
Why !important shows up in theme CSS
There are two !important sources in a block theme and they have different
justifications.
The first is the preset classes. .has-accent-color and
.has-accent-background-color are generated with !important on
purpose, so that a colour chosen in the editor beats whatever a theme's stylesheet says
about that element. This is the correct behaviour and you should leave it alone. It is
also why a colour rule in style.css that appears to be ignored is probably
losing to a class the editor applied, not to your own later rule.
The second is the responsive visibility CSS from WordPress 7.0, which is
display:none !important inside a media query. That one is deliberate too, and
the reason is the same shape: it has to beat whatever the theme says about that element.
The trade is that a theme cannot override it with a plain rule.
A theme reaching for !important for a third reason, to beat another
theme's or a plugin's specificity, is a symptom. The fix is usually to raise the selector
to 0-2-0 by writing .wp-block-group.is-style-x instead of adding weight to the
declaration.
contentSize and wideSize become two variables, and one selector reads them
settings.layout.contentSize and settings.layout.wideSize are
the only two values in theme.json that control width, and they do it by
becoming custom properties that a single generated rule consumes.
{
"settings": {
"layout": {
"contentSize": "44rem",
"wideSize": "64rem"
}
}
}
:root {
--wp--style--global--content-size: 44rem;
--wp--style--global--wide-size: 64rem;
}
.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)) {
max-width: var(--wp--style--global--content-size);
margin-left: auto !important;
margin-right: auto !important;
}
.is-layout-constrained > .alignwide {
max-width: var(--wp--style--global--wide-size);
}
Two things follow that are easy to miss. A block only respects these widths if it
carries is-layout-constrained, which comes from its layout attribute rather
than from the theme. And alignwide is a cap, not a width: a wide block narrower
than wideSize sits at its natural width, centred, which looks like a mistake
until you know the rule is a max-width. There is also a default width for
alignfull, which the constrained layout does not cap at all.
Because they are ordinary custom properties, your own CSS can read them. That is worth using in one case: a full-bleed section whose inner text should line up with the rest of the page.
.hero-inner {
max-width: var(--wp--style--global--content-size);
margin-inline: auto;
}
Your own tokens belong in settings.custom
settings.custom accepts any nested structure. Each leaf becomes a
--wp--custom-- property, the nesting becomes the name, camelCase becomes
kebab-case, and a forward slash in a key becomes a hyphen. Numbers are stringified, so
they arrive intact.
{
"settings": {
"custom": {
"radius": { "medium": "6px" },
"lineHeight": { "tight": 1.15 },
"border/width": { "thin": "1px" }
}
}
}
:root {
--wp--custom--radius--medium: 6px;
--wp--custom--line-height--tight: 1.15;
--wp--custom--border-width--thin: 1px;
}
The rule for choosing between --wp--preset--* and
--wp--custom--* is who gets to pick the value. A preset is something the
editor can choose, so it needs a slug, a name and a place in the picker. A custom token
is something only your CSS reads: a border radius, an easing curve, a shadow, a rule
width. Putting a value in settings.custom that the editor should be able to
change is how you end up with a hard-coded colour in a file nobody thinks to look at.
Keep a fallback when you consume them. A custom property that exists because one JSON file is present is a silent failure when a style variation replaces that file, and a border radius is not worth a broken layout.
.wp-element-button {
border-radius: var(--wp--custom--radius--medium, 4px);
}
A block stylesheet rides along with the block that needs it
The fourth home is for a block you are shipping, and the important part is not the CSS file. It is that WordPress loads it only when the block is on the page.
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "wlab/notice",
"title": "Notice",
"category": "text",
"supports": { "html": false },
"style": "file:./style.css",
"editorStyle": "file:./editor.css"
}
Register that block and WordPress creates two handles whose names are derived from the
block name: wlab-notice-style and wlab-notice-editor-style. The
naming rule replaces the slash with a hyphen and appends the field, so
core/paragraph would become wp-block-paragraph instead. I
registered the block above on WordPress 7.0.7 and read the handles back out of the
registry to confirm both, including the resolved file path on disk.
On a default install, wp_should_load_block_assets_on_demand() returns true,
and the style handle is enqueued by a render_block callback that only fires
when a block of that type actually renders. A page without your notice block does not load
its stylesheet. That is the argument for a block stylesheet over a rule in the theme's
style.css: the rule is not merely organised; its stylesheet is not downloaded
by pages that do not need it.
The consequence to plan for is that you cannot rely on the block stylesheet existing for markup you produce outside the block. If a pattern hard-codes your block's classes without using the block, the CSS is registered but never enqueued, and the pattern renders unstyled.
Hiding a block has two answers and only one of them is honest
WordPress 7.0 ships a visibility control, and it is better than the
display:none rule most themes use. It is worth knowing what each one actually
does to the page.
Set the block's visibility metadata and the server does one of two things. When the block is hidden entirely, the render callback returns an empty string and the block is not in the HTML at all. When it is hidden at specific viewports, WordPress adds a class and prints a media query.
<!-- wp:paragraph {"metadata":{"blockVisibility":{"viewport":{"mobile":false,"tablet":false}}}} -->
<p>Hello</p>
<!-- /wp:paragraph -->
@media (width <= 480px) {
.wp-block-hidden-mobile { display: none !important; }
}
@media (480px < width <= 782px) {
.wp-block-hidden-tablet { display: none !important; }
}
The breakpoints are 480px and 782px, and the media queries use the CSS range syntax
rather than min-width and max-width. The rendered paragraph keeps
a class for each viewport it is hidden at. Setting
blockVisibility: false instead removes the block from the output completely,
which I verified by rendering both forms and reading the strings.
Now the honest part. Both of these remove content visually, and neither removes it from the document. That distinction matters more than the accessibility-tree question, which people tend to argue about first.
A screen reader is running inside a browser, so a block hidden with
display:none is out of the accessibility tree on that browser and is not
announced. That is genuinely fine for a decorative element. The trouble is that
display:none is a fact about a stylesheet, not a fact about the page. The
paragraph is still in the HTML, still in the sitemap, still in the RSS feed, and still in
what a reader gets when the stylesheet fails to load. If the content is meaningful, you
have hidden it from sighted users at some widths while leaving it visible to every
consumer that does not run your CSS, and the version of the page that the search engine
indexes now disagrees with the version people see.
The version that survives review is the one that does not render. If a block exists only for a desktop layout, filter it out on the server rather than hiding it in the browser. A small amount of PHP does this honestly:
add_filter( 'render_block', function ( $block_content, $block ) {
if ( 'wlab/notice' !== $block['blockName'] ) {
return $block_content;
}
if ( wp_is_mobile() && empty( $block['attrs']['keepOnMobile'] ) ) {
return '';
}
return $block_content;
}, 10, 2 );
Two cautions on that snippet. wp_is_mobile() is a user-agent test, not a
viewport test, so it disagrees with your CSS breakpoints by design and should not be used
to reproduce them. And a filter has no cache context, so if the page is cached the
decision is cached with it. Server-side hiding is the honest option for content that
should not exist for some readers; it is the wrong tool for a layout tweak, where the
honest option is to let the block render and let CSS place it.
What no option here fixes is the content itself. A hero image hidden on mobile because the heading is unreadable over it is a design problem, not a visibility problem, and the media query is where it gets concealed rather than solved.
Take three rules you have written for blocks and place each one deliberately: one
in theme.json as a per-block style, one in the theme's
style.css, and one as per-block custom CSS entered on a single block in
the editor. For each, write down the selector WordPress printed and its specificity.
Load the page and confirm by reading the page source, not by looking at the rendered
result.
Then add settings.layout.contentSize and wideSize to your
theme, find the two custom properties they produce, and use one of them in
style.css for a section that has to line up with constrained content.
Change contentSize and reload to confirm your rule follows it.
Finally, build a page with a paragraph that should only appear on desktop. Do it
three ways: with a display:none media query in your own CSS, with the
visibility metadata, and by not rendering it on the server. Resize the window for each
one, then turn CSS off entirely in your browser and look at the three pages again.
Report which of the three leaves the paragraph in the HTML, and which of them you would ship for a paragraph that contains the only description of a product.
Show the solution and the reasoning
The selectors should come out as follows. A per-block style in
theme.json targeting a paragraph gives :root :where(p) at
0-1-0. The same rule written as p { } in style.css is 0-0-1
and loses to the theme.json version, which is the opposite of what most
people expect when they move a rule out of JSON and into CSS. Per-block custom CSS gives
:root :where(.wp-custom-css-0f7af4e9), also 0-1-0, and the block also gains
a has-custom-css class you did not write.
Because two of the three land on 0-1-0, the winner between them is decided by source
order. Global styles print in the head, and per-block custom CSS is an inline style
attached to the wp-block-custom-css handle, which WordPress registers with
global-styles as a dependency so that it is emitted after global styles.
That ordering is why per-block custom CSS beats a theme.json block style
without either one being more specific. Relying on it is worse than relying on
specificity, because source order is the part of the cascade people move around for
unrelated reasons.
On the layout variables: both of the rules you will find are max-width
caps, and the alignfull case has no cap from the constrained layout. If
your section lines up when contentSize changes, you used the variable
rather than a literal, and that is the whole test. If it does not move, check that the
block is inside an element with is-layout-constrained, because a variable
nothing consumes changes nothing.
The visibility answer is the point of the lab. Both the CSS media query and the visibility metadata leave the paragraph in the HTML. Only the server-side version removes it. With CSS disabled you are reading the document, which is what a screen reader, a feed reader and a search crawler are doing, and the paragraph is there in two of the three pages.
For a paragraph that is the only description of a product, I would ship the server-side version or no hiding at all. The metadata control is the right tool for a decorative element that genuinely has no meaning on a small screen, and for the case where the designer wants the desktop composition and is willing to accept that the content is still in the document. The CSS media query is the right tool for nothing, because the metadata control does the same thing with a documented API and a stable class name instead of a class you maintain by hand.
There is a fourth option worth naming, which is to render the block everywhere and
change its presentation. A hero that needs hiding on mobile often needs to be a different
layout on mobile, and an alignfull group that becomes a stacked column keeps
the content available at every width. That is more work than a media query and it is the
version that does not need a lab exercise to justify.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.