Skip to content
System nominalUTC --:--:--Articles 080
MODULE 05 Lesson 15 / 18 40 MIN

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 livesSelector WordPress printsSpecificity
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.csswhatever you wroteyours
A block stylesheet via block.jsonwhatever you wroteyours
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.

Lab 40 minutes

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.