theme.json Styles

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

The styles object of theme.json is where a block theme declares its look: the colours, fonts, spacing and borders of the page as a whole, of HTML elements such as links and buttons, and of each block type. WordPress turns it into a generated stylesheet, merges it with core’s defaults, a parent theme and the site owner’s Styles changes, and lets the owner change every value in the Site Editor afterwards. This page covers that object for theme developers: each part of it, the CSS each part produces, the state and responsive keys added in 7.0 and 7.1, and the cases where a plain stylesheet is still the right tool.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, theme.json version 3 (current since 6.6) with the https://schemas.wp.org/wp/7.1/theme.json schema, and WP-CLI 2.12.0, and checked on 2026-10-28. The Gutenberg plugin (24.2.0) previews further keys before core ships them; nothing on this page requires it. WordPress 7.2 is in beta and due in December 2026. Every generated rule shown below was printed by WordPress 7.1.3 with the lumen theme active.

The other half of the file, settings (palettes, font sizes, spacing scale, layout widths, appearanceTools, the viewport breakpoints themselves), is covered in theme.json Settings. The site owner’s side of the same styles (the Styles panel, Additional CSS, revisions) is in Site Editor and Global Styles.

How styles are applied

theme.json is read by the WP_Theme_JSON class. For styles, it does four things:

  1. Sanitizes the tree against a schema built from the registered blocks and the theme’s viewport settings. Keys that the schema does not know are dropped silently; nothing is logged.

  2. Merges the origins in order: core defaults < parent theme < child theme < user (the Styles changes the owner saved, stored in a wp_global_styles post). A later origin overrides an earlier one key by key.

  3. Resolves references: var:preset|color|ink becomes var(--wp--preset--color--ink), and ref objects are replaced by the value they point to.

  4. Generates CSS with one rule per node (the root, each element, each block, each state), printed as the inline style global-styles-inline-css on the front end and in the editor canvas.

The parts of styles and what they target:

Key Targets Since

top-level properties (color, typography, spacing and so on)

the body element; blockGap becomes the --wp--style--block-gap custom property

5.8

elements.<name>

HTML elements anywhere on the page (links, headings, buttons, captions, citations, form fields)

5.9 (form fields 6.9)

elements.<name>.:hover and other pseudo-classes

states of links and buttons only

6.1

blocks.<namespace/name>

every instance of one block type, through the selector in its block.json

5.8

blocks.<name>.elements

elements inside one block type

5.9

blocks.<name>.variations.<style>

a registered block style variation such as the Button’s Outline

6.2

blocks.<name>.:hover and other pseudo-classes

Button (7.0) and Navigation Link (7.1)

7.0

blocks.core/navigation-link.-current

the menu item of the page being viewed

7.1

blocks.<name>.@mobile, @tablet

one block type inside a viewport range

7.1

css (top level or per block)

custom CSS that no other key can express

6.2

Values set by the site owner in Styles, or on one block in the editor, win over the theme. A value that the owner never touched follows theme.json, so a theme update that changes it reaches every site that did not override it.

The Lumen styles

The lumen block theme of Lumen Press, the fictitious publisher used across this section, keeps all of its site-wide styling in theme.json. The settings part below is reduced to what the styles reference (the full file is built in theme.json Settings); font sizes small to x-large and spacing sizes 20 to 80 are core’s defaults, which version 3 keeps enabled.

The theme folder holds style.css (header only), theme.json (below), templates/index.html, and a functions.php with two small per-block stylesheets under assets/css/blocks/ (see "Where theme CSS still belongs").

{
	"$schema": "https://schemas.wp.org/wp/7.1/theme.json",
	"version": 3,
	"settings": {
		"appearanceTools": true,
		"color": {
			"palette": [
				{ "slug": "paper", "name": "Paper", "color": "#fbf8f1" },
				{ "slug": "ink", "name": "Ink", "color": "#1f1d1a" },
				{ "slug": "lumen-red", "name": "Lumen red", "color": "#a3271f" },
				{ "slug": "lumen-red-dark", "name": "Lumen red (dark)", "color": "#7a1d17" }
			]
		},
		"layout": { "contentSize": "680px", "wideSize": "1140px" },
		"typography": {
			"fontFamilies": [
				{ "slug": "source-serif", "name": "Source Serif", "fontFamily": "\"Source Serif 4\", Georgia, serif" },
				{ "slug": "system-sans", "name": "System sans", "fontFamily": "system-ui, -apple-system, \"Segoe UI\", sans-serif" }
			]
		},
		"viewport": { "mobile": "30rem", "tablet": "48rem" }
	},
	"styles": {
		"color": { "background": "var:preset|color|paper", "text": "var:preset|color|ink" },
		"typography": {
			"fontFamily": "var:preset|font-family|source-serif",
			"fontSize": "var:preset|font-size|medium",
			"lineHeight": "1.6"
		},
		"spacing": {
			"blockGap": "var:preset|spacing|40",
			"padding": { "top": "0", "right": "var:preset|spacing|40", "bottom": "0", "left": "var:preset|spacing|40" }
		},
		"elements": {
			"link": {
				"color": { "text": "var:preset|color|lumen-red" },
				"typography": { "textDecoration": "underline" },
				":hover": {
					"color": { "text": "var:preset|color|lumen-red-dark" },
					"typography": { "textDecoration": "none" }
				},
				":focus-visible": {
					"outline": { "color": "var:preset|color|lumen-red", "offset": "2px", "style": "solid", "width": "2px" }
				}
			},
			"heading": {
				"typography": { "fontFamily": "var:preset|font-family|system-sans", "fontWeight": "700", "lineHeight": "1.2" }
			},
			"h1": { "typography": { "fontSize": "var:preset|font-size|x-large" } },
			"button": {
				"color": { "background": "var:preset|color|lumen-red", "text": "var:preset|color|paper" },
				"border": { "radius": "4px" },
				"typography": { "fontFamily": "var:preset|font-family|system-sans", "fontWeight": "600" },
				":hover": { "color": { "background": "var:preset|color|lumen-red-dark" } },
				":focus-visible": {
					"outline": { "color": "var:preset|color|ink", "offset": "2px", "style": "solid", "width": "2px" }
				},
				":active": { "color": { "background": "var:preset|color|ink" } }
			},
			"caption": { "typography": { "fontSize": "var:preset|font-size|small", "fontStyle": "italic" } },
			"cite": { "typography": { "fontStyle": "normal", "textTransform": "uppercase", "letterSpacing": "0.05em" } },
			"textInput": {
				"border": { "color": "var:preset|color|ink", "radius": "4px", "style": "solid", "width": "1px" },
				"color": { "background": "var:preset|color|paper", "text": "var:preset|color|ink" },
				"spacing": { "padding": { "top": "0.5em", "right": "0.75em", "bottom": "0.5em", "left": "0.75em" } }
			},
			"select": {
				"border": { "color": "var:preset|color|ink", "radius": "4px", "style": "solid", "width": "1px" },
				"color": { "background": "var:preset|color|paper", "text": "var:preset|color|ink" }
			}
		},
		"blocks": {
			"core/site-title": {
				"typography": { "fontFamily": "var:preset|font-family|system-sans", "fontWeight": "800" },
				"elements": {
					"link": { "color": { "text": "var:preset|color|ink" }, "typography": { "textDecoration": "none" } }
				}
			},
			"core/post-title": {
				"typography": { "fontSize": "var:preset|font-size|x-large" },
				"@tablet": { "typography": { "fontSize": "var:preset|font-size|large" } },
				"@mobile": { "typography": { "fontSize": "var:preset|font-size|medium" } }
			},
			"core/quote": {
				"border": { "left": { "color": "var:preset|color|lumen-red", "style": "solid", "width": "4px" } },
				"spacing": { "padding": { "left": "var:preset|spacing|30" } }
			},
			"core/group": {
				"@mobile": { "spacing": { "padding": { "right": "var:preset|spacing|20", "left": "var:preset|spacing|20" } } }
			},
			"core/button": {
				":hover": { "color": { "background": "var:preset|color|ink", "text": "var:preset|color|paper" } },
				":focus-visible": {
					"outline": { "color": "var:preset|color|lumen-red", "offset": "3px", "style": "solid", "width": "2px" }
				},
				"@mobile": {
					"dimensions": { "width": "100%" },
					":hover": { "color": { "background": "var:preset|color|lumen-red-dark" } }
				},
				"variations": {
					"outline": {
						"border": { "color": "currentColor", "style": "solid", "width": "2px" },
						"color": { "background": "transparent", "text": "var:preset|color|lumen-red" },
						":hover": { "color": { "background": "var:preset|color|lumen-red", "text": "var:preset|color|paper" } }
					}
				}
			},
			"core/navigation-link": {
				"color": { "text": "var:preset|color|ink" },
				":hover": { "color": { "text": "var:preset|color|lumen-red" } },
				"-current": {
					"typography": { "fontWeight": "700" },
					"border": { "bottom": { "color": "var:preset|color|lumen-red", "style": "solid", "width": "2px" } },
					":hover": { "color": { "text": "var:preset|color|lumen-red-dark" } }
				}
			},
			"core/table": { "css": "& :is(td, th) { font-variant-numeric: tabular-nums; }" }
		},
		"css": ".lp-book-isbn { font-family: ui-monospace, monospace; letter-spacing: 0.02em; }"
	}
}

The $schema line gives editors such as VS Code completion and validation for the exact WordPress version; the file above validates against the 7.1 schema. The style.css header of the theme carries Theme Name: Lumen, Text Domain: lumen and Requires at least: 6.9; the @mobile, @tablet, Navigation Link and -current keys need 7.1 and are ignored by older versions, which keep the rest of the styles.

On the standard Lumen Press Compose stack from Local Development Environments, which mounts ./themes/lumen into the container, the theme is activated and the generated CSS printed with:

docker compose run --rm cli wp theme activate lumen
docker compose run --rm cli wp eval 'echo wp_get_global_stylesheet( array( "styles" ) );'
docker compose run --rm cli wp eval 'echo wp_get_global_stylesheet( array( "custom-css" ) );'

wp_get_global_stylesheet() accepts the types variables (the --wp--preset--* and --wp--custom--* properties), presets (the .has-* utility classes), styles (everything on this page except the top-level css) and custom-css (the top-level css of the theme followed by the user’s Additional CSS). The output excerpts on the rest of this page come from these commands, one rule per line.

Root styles

The top-level properties style the body element. The groups and properties accepted by 7.1.3 are:

Group Properties

background

backgroundImage, backgroundPosition, backgroundRepeat, backgroundSize, backgroundAttachment, gradient

border

color, radius, style, width, and per side top, right, bottom, left (each with color, style, width)

color

background, gradient, text

dimensions

aspectRatio, height, minHeight, minWidth, width

filter

duotone

outline

color, offset, style, width

shadow

a single value (a preset reference or a CSS box-shadow)

spacing

margin, padding, blockGap

typography

fontFamily, fontSize, fontStyle, fontWeight, letterSpacing, lineHeight, textAlign, textColumns, textDecoration, textIndent, textShadow, textTransform, writingMode

css

a string of custom CSS

The living reference in the Block Editor Handbook tracks the Gutenberg plugin, so it can list properties that core does not accept yet. In October 2026 it lists background.backgroundClip, which WordPress 7.1.3 drops; the https://schemas.wp.org/wp/7.1/theme.json schema and the 7.1.3 source are the authority for what a theme can rely on today.

The Lumen root styles produce:

:root { --wp--style--global--content-size: 680px;--wp--style--global--wide-size: 1140px; }
:root { --wp--style--block-gap: var(--wp--preset--spacing--40); }
body{background-color: var(--wp--preset--color--paper);color: var(--wp--preset--color--ink);font-family: var(--wp--preset--font-family--source-serif);font-size: var(--wp--preset--font-size--medium);line-height: 1.6;padding-top: 0;padding-right: var(--wp--preset--spacing--40);padding-bottom: 0;padding-left: var(--wp--preset--spacing--40);}

Two root properties behave differently from the others. spacing.blockGap is not printed on body: it sets --wp--style--block-gap, which the layout rules use as the default gap between blocks in flow, constrained, flex and grid layouts. spacing.padding at the root is printed on body unless settings.useRootPaddingAwareAlignments is true, in which case it moves to the .has-global-padding containers so that full-width blocks can reach the viewport edges (covered in theme.json Settings). There is no responsive key at the root: styles.@mobile is dropped (see "Responsive styles").

Referencing presets

A style value can be a literal ("1.6", "#a3271f", "4px") or a reference. References keep the design consistent and let the site owner change one preset and see it everywhere:

Written in theme.json Generated CSS Notes

var:preset|color|ink

var(--wp--preset--color--ink)

Any preset type: color, gradient, font-size, font-family, spacing, shadow, duotone, dimension. Preferred form.

var:custom|border|hairline

var(--wp--custom--border--hairline)

A value from settings.custom; camelCase keys become kebab-case.

var(--wp--preset--color--ink)

unchanged

Works, but the Site Editor shows a raw value instead of selecting the preset in its pickers.

{ "ref": "styles.elements.button.color.background" }

the resolved value of that path

Reuses another style value; one level of indirection only.

The var: form is preferred because the editor can map it back to the preset: the colour picker in Styles highlights "Ink" rather than showing a hex code, and a style variation that redefines ink changes every place that references it. The user layer that the Site Editor saves uses the same form, as Site Editor and Global Styles shows.

The var:custom and ref forms, checked with a throw-away WP_Theme_JSON object:

<?php
// wp eval-file refs.php
$theme_json = new WP_Theme_JSON(
	array(
		'version'  => 3,
		'settings' => array( 'custom' => array( 'border' => array( 'hairline' => '1px solid currentColor' ) ) ),
		'styles'   => array(
			'elements' => array( 'button' => array( 'color' => array( 'background' => 'var:preset|color|ink' ) ) ),
			'blocks'   => array(
				'core/pullquote' => array(
					'border' => array( 'top' => array( 'width' => 'var:custom|border|hairline' ) ),
					'color'  => array( 'text' => array( 'ref' => 'styles.elements.button.color.background' ) ),
				),
			),
		),
	)
);
echo $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) );
:root :where(.wp-element-button, .wp-block-button__link){background-color: var(--wp--preset--color--ink);}
:root :where(.wp-block-pullquote){border-top-width: var(--wp--custom--border--hairline);color: var(--wp--preset--color--ink);}

A reference to a preset slug that does not exist produces a var() to an undefined custom property, which the browser treats as unset: the declaration silently falls back to the inherited or initial value. A misspelt slug is the most common reason a style "does nothing".

Elements

Elements style HTML elements regardless of which block printed them. WordPress 7.1.3 knows twelve:

Element Generated selector Since

link

a:where(:not(.wp-element-button))

5.8

heading

h1, h2, h3, h4, h5, h6

6.1

h1 to h6

h1 … h6

5.9

button

.wp-element-button, .wp-block-button__link

6.1

caption

.wp-element-caption and the figcaption of the Audio, Embed, Gallery, Image, Table and Video blocks

6.1

cite

cite

6.1

textInput

textarea and input of types email, number, password, search, text, tel and url

6.9

select

select

6.9

heading sets all six levels at once; h1 to h6 override it per level. The button element covers the Button block, the Search and File blocks' buttons and the submit button of the Comments form, because core adds the wp-element-button class to all of them. The 6.9 form elements cover the Search block input, the comment form fields and the inputs that plugins print, without CSS in the theme; before 6.9 a theme needed a stylesheet for them.

Pseudo-classes on elements

Only link and button accept pseudo-classes: :link, :any-link, :visited, :hover, :focus, :focus-visible (since 6.8) and :active. WordPress prints them in that order, so the user-action states win over :visited at equal specificity. The Lumen link and button styles produce:

a:where(:not(.wp-element-button)){color: var(--wp--preset--color--lumen-red);text-decoration: underline;}
:root :where(a:where(:not(.wp-element-button)):hover){color: var(--wp--preset--color--lumen-red-dark);text-decoration: none;}
:root :where(a:where(:not(.wp-element-button)):focus-visible){outline-color: var(--wp--preset--color--lumen-red);outline-offset: 2px;outline-style: solid;outline-width: 2px;}
:root :where(.wp-element-button, .wp-block-button__link){background-color: var(--wp--preset--color--lumen-red);border-radius: 4px;border-width: 0;color: var(--wp--preset--color--paper);font-family: var(--wp--preset--font-family--system-sans);font-weight: 600;}
:root :where(.wp-element-button:hover, .wp-block-button__link:hover){background-color: var(--wp--preset--color--lumen-red-dark);}
:root :where(.wp-element-button:focus-visible, .wp-block-button__link:focus-visible){outline-color: var(--wp--preset--color--ink);outline-offset: 2px;outline-style: solid;outline-width: 2px;}
:root :where(.wp-element-button:active, .wp-block-button__link:active){background-color: var(--wp--preset--color--ink);}

The button rule is shortened here; core adds its own defaults to the same rule (font-size: inherit, the padding in em, text-decoration: none) from its base theme.json, which the theme overrides key by key.

textInput and select accept no pseudo-classes in 7.1.3: styles.elements.select.:focus is dropped. Focus styles for form fields still need CSS, which the Developer Blog’s 6.9 article also notes. A visible focus indicator on links and buttons, on the other hand, belongs in theme.json through :focus-visible and outline, so that a site owner who changes the colours keeps a working indicator. Removing the outline without a replacement fails WCAG 2.4.7; see Accessibility and Theme Accessibility and Releasing.

The other elements

h1, h2, h3, h4, h5, h6{font-family: var(--wp--preset--font-family--system-sans);font-weight: 700;line-height: 1.2;}
h1{font-size: var(--wp--preset--font-size--x-large);}
:root :where(.wp-element-caption, .wp-block-audio figcaption, .wp-block-embed figcaption, .wp-block-gallery figcaption, .wp-block-image figcaption, .wp-block-table figcaption, .wp-block-video figcaption){font-size: var(--wp--preset--font-size--small);font-style: italic;}
cite{font-style: normal;letter-spacing: 0.05em;text-transform: uppercase;}
textarea, input:where([type=email],[type=number],[type=password],[type=search],[type=text],[type=tel],[type=url]){background-color: var(--wp--preset--color--paper);border-radius: 4px;border-color: var(--wp--preset--color--ink);border-width: 1px;border-style: solid;color: var(--wp--preset--color--ink);padding-top: 0.5em;padding-right: 0.75em;padding-bottom: 0.5em;padding-left: 0.75em;}
select{background-color: var(--wp--preset--color--paper);border-radius: 4px;border-color: var(--wp--preset--color--ink);border-width: 1px;border-style: solid;color: var(--wp--preset--color--ink);}

Element rules with a plain type selector (body, h1, cite, select, the link rule) keep their natural specificity of 0-0-1; rules whose selector contains a class are wrapped in :root :where(), which fixes them at 0-1-0. The section "Specificity" explains why.

Block styles

styles.blocks styles every instance of one block type. The key is the block name (core/quote, or lumen/book-card for a custom block). The selector comes from the block’s block.json: the selectors property when the block defines one (the Button’s root selector is .wp-block-button .wp-block-button__link, its width uses .wp-block-button), otherwise .wp-block-<name>. The same properties as the root apply, plus elements (elements inside that block) and variations.

:root :where(.wp-block-site-title){font-family: var(--wp--preset--font-family--system-sans);font-weight: 800;}
:root :where(.wp-block-site-title a:where(:not(.wp-element-button))){color: var(--wp--preset--color--ink);text-decoration: none;}
:root :where(.wp-block-quote){border-left-color: var(--wp--preset--color--lumen-red);border-left-width: 4px;border-left-style: solid;padding-left: var(--wp--preset--spacing--30);}

The site title link shows why elements inside a block exist: the global link colour is Lumen red, but inside the Site Title block the link should look like plain text. Block-level element rules are printed after the global element rules and, wrapped in :root :where(), have 0-1-0 specificity against the 0-0-1 of the global link rule, so they win.

Styles that a block type sets in theme.json apply only where the block itself has no value. A colour picked on one quote in the editor is stored in that block’s attributes and printed with a higher-specificity class, so the theme cannot override it from styles.blocks.

Block style variations

A block style variation is a named alternative look, chosen in the block’s Styles panel and saved as an is-style-<name> class. styles.blocks.<name>.variations.<style> styles one that is already registered: core’s (Button outline and fill, Image and Site Logo rounded, Quote plain, Separator wide and dots, Social Icons logos-only and pill-shape, Table stripes, Tag Cloud outline) or a variation registered by a plugin or theme with register_block_style() or a styles/*.json partial with blockTypes. A key for a variation that is not registered is dropped.

Since 6.6, variation styles are not printed in the global stylesheet. WordPress adds a unique class to each block instance that uses a variation and prints the variation’s rules for that class only, in the block-style-variation-styles inline style. Rendering one Outline button:

<?php
// wp eval-file outline-button.php
echo do_blocks(
	'<!-- wp:buttons --><div class="wp-block-buttons"><!-- wp:button {"className":"is-style-outline"} -->'
	. '<div class="wp-block-button is-style-outline"><a class="wp-block-button__link wp-element-button" href="/books/">Browse the catalogue</a></div>'
	. '<!-- /wp:button --></div><!-- /wp:buttons -->'
), "\n";
echo implode( "\n", (array) wp_styles()->get_data( 'block-style-variation-styles', 'after' ) ), "\n";
<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex"><div class="wp-block-button is-style-outline is-style-outline--1"><a class="wp-block-button__link wp-element-button" href="/books/">Browse the catalogue</a></div></div>
:root :where(.wp-block-button.is-style-outline--1 .wp-block-button__link){background: transparent none;background-color: transparent;border-color: currentColor;border-width: 2px;border-style: solid;color: var(--wp--preset--color--lumen-red);padding-top: 0.667em;padding-right: 1.33em;padding-bottom: 0.667em;padding-left: 1.33em;}
:root :where(.wp-block-button.is-style-outline--1 .wp-block-button__link:hover){background-color: var(--wp--preset--color--lumen-red);color: var(--wp--preset--color--paper);}

The :hover inside the variation is the 7.0 addition: before 7.0, an Outline button’s hover state needed CSS. Variations that also restyle inner blocks and elements (section styles) are covered in Style Variations and Section Styles.

Block states: pseudo-classes and -current (7.0, 7.1)

Until 6.9, interactive states existed only on the link and button elements. A Button block with its own hover colour, or a menu item with a hover colour different from body links, needed CSS. Two releases changed that:

Version Change

7.0

:hover, :focus, :focus-visible and :active under styles.blocks.core/button and its variations, in theme.json only (no Styles UI)

7.1

The same four pseudo-classes for core/navigation-link; a custom state -current for core/navigation-link; states nested inside @mobile/@tablet; a States dropdown in Styles and in the block sidebar for both blocks; the same keys inside a block’s style attribute

No other block accepts pseudo-classes in 7.1.3: styles.blocks.core/paragraph.:hover is dropped, and so is :focus-within on any block. The Lumen states produce:

:root :where(.wp-block-button .wp-block-button__link:hover){background-color: var(--wp--preset--color--ink);color: var(--wp--preset--color--paper);}
:root :where(.wp-block-button .wp-block-button__link:focus-visible){outline-color: var(--wp--preset--color--lumen-red);outline-offset: 3px;outline-style: solid;outline-width: 2px;}
:root :where(.wp-block-navigation-link){color: var(--wp--preset--color--ink);}
:root :where(.wp-block-navigation-link:hover){color: var(--wp--preset--color--lumen-red);}
:root :where(.wp-block-navigation .current-menu-item){border-bottom-color: var(--wp--preset--color--lumen-red);border-bottom-width: 2px;border-bottom-style: solid;font-weight: 700;}
:root :where(.wp-block-navigation .current-menu-item:hover){color: var(--wp--preset--color--lumen-red-dark);}

Two details matter here:

  • The Button block’s state rules use the block’s own selector, .wp-block-button .wp-block-button__link, and come after the button element rules, so on a Button block the block-level hover (Ink) wins over the element hover (dark red), while the Search block’s button keeps the element hover.

  • -current is a custom state, written with a leading hyphen to distinguish it from CSS pseudo-classes and viewport keys. Its selector is declared in the block’s block.json under selectors.states; in 7.1.3 the Navigation Link block maps it to .wp-block-navigation .current-menu-item, the class WordPress adds to the item that links to the page being viewed. Pseudo-classes nest inside it (-current then :hover). It has no UI in 7.1 and no other block defines a custom state, so -current under core/button is dropped.

The 7.1 dev note gives .wp-block-navigation-link .current-menu-item as its example of the expanded selector; the shipped block.json uses .wp-block-navigation .current-menu-item, which is what the generated CSS above shows.

States on a single block

The same keys work inside the style attribute of one block instance, which is what the States dropdown in the block sidebar writes. WordPress adds a generated wp-states-<hash> class to the block and prints the rules with !important, so they win over the block’s own inline styles:

<?php
// wp eval-file button-instance-states.php
echo do_blocks(
	'<!-- wp:button {"style":{":hover":{"color":{"background":"var:preset|color|ink"}},"@mobile":{"typography":{"fontSize":"1rem"}}}} -->'
	. '<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="/books/">Browse</a></div>'
	. '<!-- /wp:button -->'
), "\n";
echo wp_style_engine_get_stylesheet_from_context( 'block-supports' ), "\n";
<div class="wp-block-button wp-states-29dc42fe"><a class="wp-block-button__link wp-element-button" href="/books/">Browse</a></div>
.wp-states-29dc42fe .wp-block-button__link:hover{background-color:var(--wp--preset--color--ink) !important;background-image:unset !important;}@media (width <= 30rem){.wp-states-29dc42fe .wp-block-button__link{font-size:1rem !important;}}

The editor controls can be hidden with the blockStatesEditingEnabled and responsiveEditingEnabled editor settings (a block_editor_settings_all filter, shown in Site Editor and Global Styles); saved state styles keep rendering either way.

Responsive styles (7.1)

Before 7.1 a theme.json value applied at every screen width, and a smaller title on phones needed a media query in CSS. WordPress 7.1 adds two viewport states, @mobile and @tablet, whose ranges come from settings.viewport:

Key Default media query With Lumen’s settings.viewport (30rem, 48rem)

@mobile

@media (width <= 480px)

@media (width <= 30rem)

@tablet

@media (480px < width <= 782px)

@media (30rem < width <= 48rem)

There is no @desktop key (it is dropped): the base value is the desktop value and applies at every width that no viewport key overrides. settings.viewport accepts only non-negative px, em or rem lengths, is site-wide (not per block), and also drives block visibility and the editor’s device preview. An invalid value is ignored and the default is used; if tablet is not larger than mobile, only the mobile range is generated. Lumen uses rem breakpoints so that the ranges follow the reader’s browser font size.

Where the viewport keys are accepted in 7.1.3:

Location Result

styles.blocks.<name>.@mobile (any block, any style property, plus elements)

rendered

inside a block style variation (variations.<style>.@tablet)

rendered

a pseudo-class inside a viewport key (core/button → @mobile → :hover)

rendered

styles.@mobile (the root)

dropped

styles.elements.h1.@mobile (top-level elements)

kept by the sanitizer, rejected by the 7.1 schema, never rendered

The last row is a trap: no error appears and the value even survives in the merged data, but no CSS is generated. A responsive heading size is written on a block instead, as Lumen does for the Post Title block. The Lumen viewport styles produce:

@media (width <= 30rem){:root :where(.wp-block-button){width: calc(100 * 1% - (var(--wp--style--block-gap, 0.5em) * (1 - 100 / 100)));}}
@media (width <= 30rem){:root :where(.wp-block-button .wp-block-button__link:hover){background-color: var(--wp--preset--color--lumen-red-dark);}}
:root :where(.wp-block-post-title){font-size: var(--wp--preset--font-size--x-large);}
@media (width <= 30rem){:root :where(.wp-block-post-title){font-size: var(--wp--preset--font-size--medium);}}
@media (30rem < width <= 48rem){:root :where(.wp-block-post-title){font-size: var(--wp--preset--font-size--large);}}
@media (width <= 30rem){:root :where(.wp-block-group){padding-right: var(--wp--preset--spacing--20);padding-left: var(--wp--preset--spacing--20);}}

The Button’s dimensions.width at @mobile goes through the Button block’s own width support, which converts a percentage into a calc() that accounts for the gap between buttons. The media queries use the range syntax (width <= 30rem), supported by all current browsers; very old browsers ignore the whole rule and fall back to the base value. Fluid font size presets (clamp()) already scale with the viewport, so viewport keys are best used for changes that fluid values cannot express: a different preset, padding, a full-width button, a hidden border.

Custom CSS: the css property

css takes a string of CSS. It exists for the few things the style properties cannot express, and it is editable by the site owner in Styles (Additional CSS for the top level, Additional block CSS per block), unlike a stylesheet.

  • Top level (styles.css): a full stylesheet with selectors. It is printed in the custom-css part of the global stylesheet, before the owner’s Additional CSS, so the owner can override it.

  • Per block (styles.blocks.<name>.css): declarations for the block’s own selector, or nested rules with & standing for that selector. It is printed with the block’s other styles, wrapped in :root :where().

.lp-book-isbn { font-family: ui-monospace, monospace; letter-spacing: 0.02em; }
:root :where(.wp-block-table > table :is(td, th)){font-variant-numeric: tabular-nums;}

The Table block’s selector is .wp-block-table > table (from its block.json), so & expands to that. A comma-separated list of nested selectors does not survive this processing in 7.1.3: & td, & th { … } produced the broken rule :root :where(.wp-block-table > table){td,} followed by a rule for th only. Group the selectors with :is() as above, or write one nested rule per selector.

Rules of thumb from the Developer Blog: a few lines of CSS in JSON are acceptable, anything longer belongs in a stylesheet; and CSS in theme.json cannot contain anything that looks like an HTML tag (it is stripped), so a </style> cannot be smuggled in. In the user layer, saving CSS requires the edit_css capability.

Specificity

Before 6.6, generated rules had uneven specificity: some block rules beat theme CSS, others lost to it, and themes answered with !important. WordPress 6.6 normalised the output: every rule that global styles generates for blocks, block style variations, the button and caption elements and all pseudo-class states is wrapped in :root :where(), so its specificity is exactly 0-1-0 (the :root pseudo-class; :where() adds nothing). Type-selector rules (body, h1, cite, select, the base link rule) keep 0-0-1. The root body rule briefly got the wrapper in 6.6 and was restored to a plain body selector in a 6.6 maintenance release.

Generated rule (Lumen) Specificity

body{…}

0-0-1

a:where(:not(.wp-element-button)){…}

0-0-1

:root :where(a:where(:not(.wp-element-button)):hover){…}

0-1-0

:root :where(.wp-element-button, .wp-block-button__link){…}

0-1-0

:root :where(.wp-block-quote){…}

0-1-0

:root :where(.wp-block-button.is-style-outline--1 .wp-block-button__link){…}

0-1-0

@media (width <= 30rem){:root :where(.wp-block-post-title){…}}

0-1-0

.wp-states-29dc42fe .wp-block-button__link:hover{… !important} (one block instance)

0-2-1, important

The consequences for a theme:

  • Any theme or plugin rule with one class (.wp-block-quote { … }) ties with global styles, and the one printed later wins. Global styles are printed in the head after the block library styles, so a theme stylesheet enqueued normally usually comes after them too; relying on order is fragile, and two classes (0-2-0) win regardless.

  • Raising specificity to beat global styles also beats the site owner’s Styles changes, which use the same selectors. That is the strongest reason to express the theme’s defaults in theme.json rather than CSS: Styles can then override them.

  • Layout rules (.is-layout-flow > *, .is-layout-constrained > …) keep higher specificity on purpose, so block gap and alignment work even when a theme sets margins on blocks.

Checking what WordPress keeps

Because unknown keys are dropped silently, a typo ("colour"), a key from a newer version or a key from the Gutenberg plugin’s living reference simply has no effect. Two checks catch this. The first is the schema: the $schema line makes the editor flag every invalid key, and a JSON Schema validator in CI does the same. The second asks WordPress itself which keys survive sanitization:

<?php
/**
 * Lists theme.json style keys that WordPress drops while sanitizing.
 * Usage: wp eval-file dropped-keys.php [path/to/theme.json]
 */
$file  = $args[0] ?? get_stylesheet_directory() . '/theme.json';
$input = wp_json_file_decode( $file, array( 'associative' => true ) );
$kept  = ( new WP_Theme_JSON( $input ) )->get_raw_data();

$walk = static function ( array $in, $out, string $path ) use ( &$walk ) {
	foreach ( $in as $key => $value ) {
		$here    = '' === $path ? (string) $key : "$path.$key";
		$missing = ! is_array( $out ) || ! array_key_exists( $key, $out );
		$state   = in_array( substr( (string) $key, 0, 1 ), array( ':', '@', '-' ), true );
		if ( $missing && ( $state || ! is_array( $value ) ) ) {
			WP_CLI::log( "dropped: $here" );
		} elseif ( is_array( $value ) && ! wp_is_numeric_array( $value ) ) {
			$walk( $value, $missing ? array() : $out[ $key ], $here );
		}
	}
};
$walk( $input['styles'] ?? array(), $kept['styles'] ?? array(), 'styles' );
WP_CLI::success( 'Checked ' . basename( $file ) );

Against the Lumen theme.json it prints only the success line. Against a file that uses keys from the wrong places:

{
	"version": 3,
	"settings": { "viewport": { "mobile": "30rem", "tablet": "48rem" } },
	"styles": {
		"@mobile": { "color": { "text": "var:preset|color|ink" } },
		"background": { "backgroundClip": "text" },
		"elements": { "select": { ":focus": { "outline": { "width": "2px" } } } },
		"blocks": {
			"core/paragraph": { ":hover": { "color": { "text": "var:preset|color|lumen-red" } } },
			"core/button": { "@desktop": { "dimensions": { "width": "auto" } } }
		}
	}
}
dropped: styles.@mobile
dropped: styles.background.backgroundClip
dropped: styles.elements.select.:focus
dropped: styles.blocks.core/paragraph.:hover
dropped: styles.blocks.core/button.@desktop
Success: Checked bad.json

The script runs with docker compose run --rm cli wp eval-file wp-content/themes/lumen/dropped-keys.php and should not ship in the theme. It does not report styles.elements.h1.@mobile, which the sanitizer keeps but the renderer ignores; the 7.1 schema does flag that one.

Where theme CSS still belongs

The Developer Blog’s advice, "you might not need CSS", holds for most of a block theme: colours, type, spacing, borders, shadows, element and block states, and since 7.1 most responsive changes have a theme.json key. What is left for a stylesheet:

Need Where it goes

Colours, fonts, sizes, spacing, borders, radius, shadows

theme.json styles, referencing presets

Hover, focus and active on links, buttons, Button and Navigation Link blocks

theme.json pseudo-classes

Smaller sizes or different padding on phones and tablets

theme.json @mobile / @tablet on blocks

Focus styles on textInput and select; states on other blocks

CSS

Pseudo-elements (::marker, ::first-letter, ::before), transitions and animations, prefers-reduced-motion, prefers-color-scheme, container queries

CSS

Media queries on top-level elements, or ranges other than the two viewports

CSS

A new block style ("Lumen card")

register_block_style() or a styles/*.json partial, plus CSS if it needs more than style properties

A few lines tied to one block type, that the owner should be able to edit

css in styles.blocks

CSS that belongs to one block type is best loaded only when that block is on the page. wp_enqueue_block_style() (since 5.9) attaches a stylesheet to a block; block themes load block assets on demand, so the file is printed only on pages that render the block, and it is also loaded in the editor canvas:

<?php
/**
 * Lumen theme functions.
 *
 * @package lumen
 */

/**
 * Loads one small stylesheet per block, only on pages that render that block.
 */
function lumen_enqueue_block_styles() {
	$blocks = array( 'core/details', 'core/navigation' );

	foreach ( $blocks as $block_name ) {
		$slug = str_replace( '/', '-', $block_name );

		wp_enqueue_block_style(
			$block_name,
			array(
				'handle' => "lumen-{$slug}",
				'src'    => get_theme_file_uri( "assets/css/blocks/{$slug}.css" ),
				'path'   => get_theme_file_path( "assets/css/blocks/{$slug}.css" ),
				'ver'    => wp_get_theme()->get( 'Version' ),
			)
		);
	}
}
add_action( 'init', 'lumen_enqueue_block_styles' );
/* assets/css/blocks/core-details.css: pseudo-elements have no theme.json key. */
.wp-block-details summary::marker {
	color: var(--wp--preset--color--lumen-red);
}

/* assets/css/blocks/core-navigation.css: transitions and motion preferences have no theme.json key. */
@media (prefers-reduced-motion: no-preference) {
	.wp-block-navigation-item__content {
		transition: color 150ms ease-in-out;
	}
}

The path argument lets WordPress inline a small file instead of adding a request. Rendering only a Details block shows that only its stylesheet is enqueued:

docker compose run --rm cli wp eval 'do_blocks( "<!-- wp:details --><details class=\"wp-block-details\"><summary>ISBN</summary></details><!-- /wp:details -->" ); foreach ( array( "lumen-core-details", "lumen-core-navigation" ) as $h ) { printf( "%s: %s\n", $h, wp_style_is( $h ) ? "enqueued" : "not enqueued" ); }'
lumen-core-details: enqueued
lumen-core-navigation: not enqueued

The CSS uses the preset custom properties (var(--wp--preset--color--lumen-red)) rather than hex values, so a style variation or the owner’s palette change reaches it too. Theme CSS should not restyle what theme.json already styles; two sources for one value are the usual cause of "my Styles change does nothing".

What older books get wrong or leave out

The older books used alongside this section style themes through the Customizer, a style.css full of selectors, or a page builder’s own settings; none of them covers theme.json. Pro WordPress treats themes as classic only, so every visual change is a CSS rule, often with high specificity or !important to beat plugin styles; in a block theme that approach also beats the site owner’s Styles changes, which is why the generated CSS is held at 0-1-0 and the values belong in theme.json. The Ultimate WordPress Handbook styles through the Customizer and a commercial theme’s options and describes full site editing as still maturing; in 7.1 the Customizer is hidden for block themes and the equivalent controls live in Styles, backed by the keys on this page. Hover colours, focus outlines and phone sizes, which those books solve with CSS, have theme.json keys since 6.1 (links and buttons), 7.0 (Button block) and 7.1 (Navigation Link, @mobile/@tablet). Loading Google Fonts from Google’s CDN in a stylesheet, as one book shows, is replaced by fontFace in theme.json settings or the Font Library (see Typography and Fonts).

Common errors and pitfalls

Symptom Cause and fix

A style key has no effect, no error anywhere

The key is unknown to 7.1.3 (typo, newer Gutenberg key, wrong location) and was dropped. Validate against the 7.1 schema or run the dropped-keys script.

@mobile on styles.elements.h1 does nothing

Viewport keys render only under styles.blocks (and variations). Put it on core/heading, core/post-title or another block.

:hover on a Group, Paragraph or Image block is dropped

In 7.1 only Button and Navigation Link accept block pseudo-classes; use CSS or a block style for others.

Focus style on form fields missing

textInput and select have no pseudo-classes; add a :focus-visible rule in CSS.

The colour is a hex code in the Styles picker instead of "Ink"

The value was written as a literal or var(--wp--preset--…); use var:preset|color|ink.

A value renders as the browser default

The preset slug does not exist (the var() points to an undefined property). Check settings and the variation in use.

& td, & th in block css produces a broken rule

Comma lists in nested block CSS are not split per selector; use & :is(td, th) or separate rules.

Theme CSS overrides the owner’s Styles changes

The rule has 0-2-0 or !important. Move the default into theme.json and drop the rule.

A theme update does not change a site’s colours

The owner saved Styles values (user layer) that override the theme. Reset that panel in Styles or ask the owner to.

The breakpoint in settings.viewport is ignored

Only px, em, rem are accepted; clamp(), %, vw or a unitless number fall back to the defaults.

-current styles never apply

The menu item is a custom link to a URL that is not the current page, so WordPress does not add current-menu-item.