theme.json Settings

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.

theme.json is the configuration file of a block theme. Its settings object decides what the editors offer (which colour, typography, spacing, border and layout controls appear, and which presets they list), and WordPress turns every preset into a CSS custom property and, for some presets, a utility class. Its styles object applies values to the site, elements and blocks, and is covered in theme.json Styles. This page is for theme developers: it builds the settings of the Lumen Press block theme lumen and shows, from a running site, what WordPress makes of them.

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 and WP-CLI 2.12.0, and checked on 2026-10-28. The Gutenberg plugin (24.2.0) previews settings before they reach core; nothing on this page requires it. WordPress 7.2 is in beta and due in December 2026; theme.json stays at version 3 in it so far. Every merged value and CSS variable shown below was printed by wp eval on a fresh 7.1.3 installation with the themes of this page active, and each JSON file was validated against the published schema.

How a site owner changes these values without code is covered in Site Editor and Global Styles; font families, fontFace and the Font Library in Typography and Fonts; the styles/*.json files in Style Variations and Section Styles.

What settings are for

A block theme describes its design system once, in theme.json, instead of spreading it across add_theme_support() calls, editor stylesheets and front-end CSS:

  • Presets (palette, gradients, duotone filters, font sizes, font families, spacing sizes, aspect ratios, dimension sizes, shadows, border radius sizes): named values the editor lists in its pickers. A block that uses one stores the slug, not the value ("backgroundColor":"lumen-red", or var:preset|color|lumen-red in a style attribute), so changing the preset later restyles every block that uses it.

  • Feature switches (color.custom, typography.dropCap, spacing.padding, border.radius, position.sticky and so on): whether a control appears in the block sidebar at all. They only change the editing interface; values already saved in content keep rendering.

  • Layout values (layout.contentSize, layout.wideSize) used by the Group, Post Content and other layout blocks.

  • Responsive breakpoints (viewport, 7.1) used by responsive styles and block visibility.

  • Custom properties (custom) that become --wp--custom--* CSS variables for the theme’s own CSS and blocks.

Any of these can be set for the whole site or for one block type under settings.blocks. Settings never apply a style by themselves: a palette colour colours nothing until a style or an editor uses it.

A theme does not need to be a block theme to use theme.json. A classic theme that ships one becomes a hybrid theme: its editor gets the presets and controls, and since 6.8 it gets the Style Book and the Patterns screen under Appearance > Design (see Hybrid Themes and Migration to Block Themes).

The file: $schema and version

theme.json lives in the theme’s root folder. Two top-level keys matter before any setting:

version

The schema version. It must be present: with no version key WordPress replaces the whole file with an empty version 3 document, so every setting in it is silently ignored. Version 1 arrived in WordPress 5.8 (for classic themes and the post editor), version 2 in 5.9 with block themes, and version 3 in 6.6. 7.1 still uses version 3 (WP_Theme_JSON::LATEST_SCHEMA is 3). Older files keep working: WP_Theme_JSON_Schema::migrate() upgrades them in memory each time they are read, without changing the file.

$schema

The URL of the JSON Schema, read only by editors (VS Code, PhpStorm and others) for autocompletion, descriptions and validation. WordPress ignores it at runtime. https://schemas.wp.org/trunk/theme.json follows the Gutenberg plugin’s development branch; https://schemas.wp.org/wp/7.1/theme.json matches what WordPress 7.1 supports. Both redirect to the schemas/json/theme.json file of the Gutenberg repository. Pin the version the theme declares in Requires at least or Tested up to: the trunk schema already lists settings that 7.1 does not apply (text-shadow presets and background.backgroundClip), so an editor would offer keys that do nothing on a 7.1 site.

Other top-level keys (styles, customTemplates, templateParts, patterns, title, slug, blockTypes) are covered on the styles, templates and patterns pages.

The Lumen settings

The complete theme.json of the lumen theme, used for every result on this page. The theme also has the usual style.css header (Text Domain lumen, Requires at least: 6.9) and templates/index.html, described in Themes Overview. The styles object is reduced to the root padding that useRootPaddingAwareAlignments needs; the theme’s real styles are on the styles page.

{
	"$schema": "https://schemas.wp.org/wp/7.1/theme.json",
	"version": 3,
	"settings": {
		"appearanceTools": true,
		"useRootPaddingAwareAlignments": true,
		"layout": {
			"contentSize": "680px",
			"wideSize": "1140px"
		},
		"viewport": {
			"mobile": "600px",
			"tablet": "1024px"
		},
		"color": {
			"defaultPalette": false,
			"defaultGradients": false,
			"defaultDuotone": false,
			"customGradient": false,
			"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" },
				{ "slug": "mist", "name": "Mist", "color": "#e9e4d8" },
				{ "slug": "slate", "name": "Slate", "color": "#4a4740" }
			],
			"gradients": [
				{
					"slug": "paper-to-mist",
					"name": "Paper to mist",
					"gradient": "linear-gradient(180deg, #fbf8f1 0%, #e9e4d8 100%)"
				}
			],
			"duotone": [
				{ "slug": "ink-and-paper", "name": "Ink and paper", "colors": [ "#1f1d1a", "#fbf8f1" ] }
			]
		},
		"typography": {
			"defaultFontSizes": false,
			"fluid": {
				"minViewportWidth": "360px",
				"maxViewportWidth": "1140px"
			},
			"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"
				}
			],
			"fontSizes": [
				{ "slug": "small", "name": "Small", "size": "0.875rem", "fluid": false },
				{ "slug": "medium", "name": "Medium", "size": "1.125rem", "fluid": { "min": "1rem", "max": "1.125rem" } },
				{ "slug": "large", "name": "Large", "size": "1.75rem", "fluid": { "min": "1.375rem", "max": "1.75rem" } },
				{ "slug": "x-large", "name": "Extra large", "size": "2.75rem", "fluid": { "min": "1.875rem", "max": "2.75rem" } }
			]
		},
		"spacing": {
			"defaultSpacingSizes": false,
			"units": [ "px", "rem", "%", "vw" ],
			"spacingSizes": [
				{ "slug": "20", "name": "XS", "size": "0.5rem" },
				{ "slug": "30", "name": "S", "size": "1rem" },
				{ "slug": "40", "name": "M", "size": "clamp(1.25rem, 3vw, 2rem)" },
				{ "slug": "50", "name": "L", "size": "clamp(2rem, 5vw, 3.5rem)" },
				{ "slug": "60", "name": "XL", "size": "clamp(3rem, 8vw, 6rem)" }
			]
		},
		"dimensions": {
			"defaultAspectRatios": false,
			"aspectRatios": [
				{ "slug": "book-cover", "name": "Book cover", "ratio": "2/3" },
				{ "slug": "wide", "name": "Wide", "ratio": "16/9" }
			],
			"dimensionSizes": [
				{ "slug": "cover-s", "name": "Cover small", "size": "120px" },
				{ "slug": "cover-m", "name": "Cover medium", "size": "200px" },
				{ "slug": "measure", "name": "Reading measure", "size": "65ch" }
			]
		},
		"shadow": {
			"defaultPresets": false,
			"presets": [
				{ "slug": "card", "name": "Card", "shadow": "0 1px 3px rgb(31 29 26 / 0.15)" },
				{ "slug": "lifted", "name": "Lifted", "shadow": "0 8px 24px rgb(31 29 26 / 0.2)" }
			]
		},
		"border": {
			"radiusSizes": [
				{ "slug": "small", "name": "Small", "size": "2px" },
				{ "slug": "medium", "name": "Medium", "size": "6px" },
				{ "slug": "pill", "name": "Pill", "size": "9999px" }
			]
		},
		"position": {
			"sticky": true
		},
		"blockVisibility": {
			"allowEditing": true
		},
		"custom": {
			"lineHeight": {
				"body": 1.6,
				"heading": 1.2
			},
			"bookCard": {
				"coverWidth": "var(--wp--preset--dimension--cover-m)",
				"gap": "var(--wp--preset--spacing--30)"
			}
		},
		"blocks": {
			"core/button": {
				"color": {
					"palette": [
						{ "slug": "lumen-red", "name": "Lumen red", "color": "#a3271f" },
						{ "slug": "ink", "name": "Ink", "color": "#1f1d1a" }
					]
				}
			},
			"core/code": {
				"typography": {
					"customFontSize": false,
					"fontSizes": [
						{ "slug": "small", "name": "Small", "size": "0.8125rem", "fluid": false }
					]
				}
			},
			"core/image": {
				"lightbox": {
					"enabled": true,
					"allowEditing": false
				}
			},
			"core/paragraph": {
				"typography": {
					"dropCap": false
				}
			}
		}
	},
	"styles": {
		"spacing": {
			"padding": {
				"left": "var:preset|spacing|40",
				"right": "var:preset|spacing|40"
			}
		}
	}
}

The file validates against both the wp/7.1 and the trunk schema. Any JSON Schema draft-07 validator works; with Node.js, ajv-cli checks it in one command (the schema must first be saved locally, because ajv-cli does not follow the redirect):

curl -sL -o theme.schema.json https://schemas.wp.org/wp/7.1/theme.json
npx -y ajv-cli@5.0.0 validate --spec=draft7 --strict=false -s theme.schema.json -d wp-content/themes/lumen/theme.json
# wp-content/themes/lumen/theme.json valid

--strict=false is needed because the schema uses descriptive keywords that Ajv’s strict mode rejects. Validation catches typos ("defaultPallete"), values of the wrong type and out-of-range numbers; it cannot tell whether a slug collides with a core preset, which is what the migration section below is about.

Global switches

appearanceTools

true turns on a set of controls that core leaves off by default for themes. In 7.1 it enables background image, size and gradient; border colour, radius, style and width; link, heading, button and caption colours; aspect ratio, height, min-height, min-width and width; sticky position; block gap, margin and padding; line height; and text columns. The list grows with releases (width and height were added in 7.0, minWidth and background gradients in 7.1), so a theme that turns it on also opts into future controls. To keep one of them off, set that switch to false next to appearanceTools, for instance "border": { "style": false }. After merging, wp_get_global_settings() reports appearanceTools as false and the individual switches as true: the flag is expanded and then cleared.

useRootPaddingAwareAlignments

With true, the root horizontal padding set in styles.spacing.padding is applied to blocks that use the has-global-padding class instead of to the whole page, and full-width children pull themselves back with negative margins. Full-width blocks then reach the screen edge while their content stays aligned with the rest of the page. It needs a padding value in styles to have any effect; Lumen uses the 40 spacing preset.

layout

contentSize is the default width of the content in constrained layouts (the Post Content block, Groups with "Inner blocks use content width"), and wideSize the width of alignwide blocks. They become --wp--style--global--content-size and --wp--style--global--wide-size. allowEditing: false hides the layout panel, and allowCustomContentAndWideSize: false (6.5) keeps the panel but hides the width inputs.

Colour

Key Effect

palette

The theme colours. Each entry has slug, name (translatable) and color. Generates --wp--preset--color--{slug} and the classes has-{slug}-color, has-{slug}-background-color and has-{slug}-border-color.

gradients

Gradient presets (gradient holds any CSS gradient). Generates --wp--preset--gradient--{slug} and has-{slug}-gradient-background.

duotone

Two-colour image filters (colors holds the shadow and the highlight). No CSS variable is printed up front: the SVG filter and its variable are output only when a block uses the preset.

defaultPalette, defaultGradients, defaultDuotone

false hides core’s 12 colours, 12 gradients and 8 duotones from the pickers. Their CSS variables are still printed, so content that already uses them keeps its colours.

custom, customGradient, customDuotone

false removes the free colour picker, the gradient editor or the custom duotone picker, so editors can only choose presets.

background, text, link, heading, button, caption

Show or hide each colour control. Link, heading, button and caption are off by default and turned on by appearanceTools.

Lumen hides core’s presets, adds six brand colours, one gradient and one duotone, and keeps custom colours for editors but not custom gradients. A palette should be checked for contrast before release: WordPress warns about low contrast in the block sidebar, but does not stop a combination such as slate text on ink (Accessibility).

Typography

fontSizes presets generate --wp--preset--font-size--{slug} and the class has-{slug}-font-size. With fluid turned on (true, or an object), each size becomes a clamp() that grows with the viewport:

  • the global fluid object sets minViewportWidth and maxViewportWidth (the range over which sizes scale; core’s defaults are 320px and 1600px) and minFontSize, below which sizes are not made fluid;

  • each preset may set "fluid": { "min": ..., "max": ... }, or "fluid": false to stay fixed; when a preset sets only size, WordPress derives a smaller minimum from it;

  • since 6.7 a single preset may opt into fluid sizing even when the global setting is off.

Lumen’s medium size becomes clamp(1rem, 1rem + ((1vw - 0.225rem) * 0.256), 1.125rem): at a 360px viewport 1vw equals 0.225rem and the size is the minimum; it reaches the maximum at 1140px. The maths, font families, fontFace with self-hosted WOFF2 files and the Font Library are covered in Typography and Fonts.

defaultFontSizes (6.6) decides what happens with core’s four sizes (small 13px, medium 20px, large 36px, x-large 42px). The rest of the typography switches show or hide controls: customFontSize, dropCap, fontStyle, fontWeight, letterSpacing, lineHeight, textAlign, textColumns, textDecoration, textIndent (7.0, which core sets to "subsequent"), textTransform and writingMode.

Spacing

Spacing presets fill the spacing slider of padding, margin and block gap controls, and generate --wp--preset--spacing--{slug}. There are two ways to declare them:

spacingScale

A generated scale: steps sizes around a mediumStep, multiplied (operator set to *) or added (+) by increment, in unit. Core’s own scale is 7 steps from 0.44rem to 5.06rem, multiplied by 1.5 around 1.5rem, with the slugs 20 to 80.

spacingSizes

An explicit list of slug, name and size. Sizes may be clamp() expressions or references to other variables, so fluid spacing is written here.

Version 3 changed how the two combine. In version 2, a theme’s spacingSizes replaced its spacingScale entirely; in version 3 they are merged: the scale generates its sizes, and spacingSizes entries are added, replacing generated sizes with the same slug. The lumen-scale test theme below shows it (5 generated steps and two explicit entries):

{
	"$schema": "https://schemas.wp.org/wp/7.1/theme.json",
	"version": 3,
	"settings": {
		"spacing": {
			"defaultSpacingSizes": false,
			"spacingScale": {
				"operator": "*",
				"increment": 1.5,
				"steps": 5,
				"mediumStep": 1.5,
				"unit": "rem"
			},
			"spacingSizes": [
				{ "slug": "gutter", "name": "Gutter", "size": "clamp(1rem, 4vw, 2.5rem)" },
				{ "slug": "50", "name": "Medium (fixed)", "size": "1.75rem" }
			]
		}
	}
}
theme spacingSizes: gutter, 30 (0.67rem), 40 (1rem), 50 (1.75rem), 60 (2.25rem), 70 (3.38rem)

The generated 50 (1.5rem) was replaced by the explicit one, and gutter was added in front. Lumen uses only spacingSizes and leaves spacingScale out. Turning the scale off with "steps": 0, which some themes and tutorials do, works at runtime but fails schema validation (steps has a minimum of 1); with defaultSpacingSizes set to false and no theme scale, it is unnecessary.

Other spacing keys: units (the units offered in custom inputs), customSpacingSize (false limits editors to the slider), and padding, margin and blockGap (show the controls; turned on by appearanceTools).

Dimensions, shadow, border, background and position

Key Since Effect

dimensions.aspectRatios, defaultAspectRatios

6.6

Aspect ratio presets for the Image, Cover and Featured Image blocks. Generates --wp--preset--aspect-ratio--{slug}. Lumen adds a 2/3 book-cover ratio and hides core’s seven.

dimensions.dimensionSizes

7.0

Size presets for the width, height and min-height controls. Generates --wp--preset--dimension--{slug}. Core itself defines 25%, 50%, 75% and 100% for the Button block only.

dimensions.width, height, minHeight, minWidth, aspectRatio

6.2 to 7.1

Show the corresponding controls (all turned on by appearanceTools; width and height 7.0, minWidth 7.1).

shadow.presets, defaultPresets

6.2

Box-shadow presets. Generates --wp--preset--shadow--{slug}. Core ships five (natural, deep, sharp, outlined, crisp).

border.radiusSizes

6.9

Radius presets for the border radius control. Generates --wp--preset--border-radius--{slug}. There is no default set and no defaultRadiusSizes switch.

border.color, radius, style, width

5.9

Show the border controls.

background.backgroundImage, backgroundSize, gradient

6.4, 6.5, 7.1

Show the background image, size and gradient controls. Core turns gradient on for every theme.

position.sticky, position.fixed

6.2

Show the sticky position control (Group block); fixed is accepted by the schema but no core block uses it yet.

lightbox.enabled, lightbox.allowEditing

6.4

"Expand on click" for the Image block, and whether editors may change it.

blockVisibility.allowEditing

7.1

false hides the block visibility controls (hiding a block, 6.9, and hiding it per viewport, 7.0). Blocks already hidden stay hidden. Global only.

Lumen sets lightbox under blocks.core/image rather than at the top level, because core’s own theme.json sets lightbox.allowEditing to true for core/image at block level, and a block-level value from any layer wins over a top-level value from a higher layer. A top-level "lightbox": { "allowEditing": false } therefore never reaches the Image block’s sidebar.

Viewport breakpoints (7.1)

WordPress 7.1 adds responsive styles: styles under @mobile and @tablet keys, in theme.json or saved by the editors, that apply only inside a viewport range (see theme.json Styles). settings.viewport moves the two breakpoints:

State Default media query Lumen (600px, 1024px)

@mobile

@media (width <= 480px)

@media (width <= 600px)

@tablet

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

@media (600px < width <= 1024px)

desktop (block visibility only)

@media (width > 782px)

@media (width > 1024px)

The rules, from WP_Theme_JSON::sanitize_viewport_settings():

  • values must be plain px, em or rem lengths (600px, 37.5em); calc(), vw, percentages and unitless numbers are dropped, because the values are written into media queries;

  • if only one value is valid it keeps its name and becomes a single max-width query: a mobile of 50vw with a tablet of 40rem yields only @tablet, at width <= 40rem;

  • if tablet is not larger than mobile, only mobile is kept; if neither is valid, the defaults apply;

  • viewport is global: it cannot be set under settings.blocks, and there is no @desktop style key (the default style is the desktop style).

The same breakpoints drive block visibility per viewport and the editor’s device preview. The checks above were run with wp eval against the public helper:

wp eval 'print_r( WP_Theme_JSON::get_viewport_media_queries(
	wp_get_global_settings( array( "viewport" ) ), array( "include_desktop" => true ) ) );'
Array
(
    [@mobile] => @media (width <= 600px)
    [@tablet] => @media (600px < width <= 1024px)
    [@desktop] => @media (width > 1024px)
)

Custom properties

settings.custom accepts any nested object. WordPress flattens it into --wp--custom-- variables, joining levels with -- and turning camelCase into kebab-case: custom.bookCard.coverWidth becomes --wp--custom--book-card--cover-width. Values are copied as they are, so they may reference presets. Lumen keeps its line heights and the measurements of the lumen/book-card block there, so the block’s stylesheet uses var(--wp--custom--book-card--cover-width) and a style variation or child theme can change the card without touching the block.

Two cautions: camelCase conversion means bookCard and book-card produce the same variable, and numbers stay unitless (1.6), so a value meant as a length needs its unit in the JSON.

Per-block settings

Every setting except viewport and blockVisibility can be repeated under settings.blocks.<block name> to change what the editor offers for that block type only. Lumen:

  • limits the Button block to two colours (lumen-red, ink); the variables are re-declared on the button’s selector (.wp-block-button .wp-block-button__link) and the colour classes are scoped to it;

  • gives the Code block its own small size (0.8125rem) and no custom sizes; the variable is re-declared on .wp-block-code, so has-small-font-size means a different size inside code blocks only;

  • hides the drop cap for paragraphs;

  • turns the Image lightbox on and locks it.

Block settings of custom blocks work the same way (settings.blocks.lumen/book-card), and blocks can declare their own defaults in block.json supports, which form the "blocks" layer below the theme (see Attributes, Supports and InnerBlocks).

Layers: core, parent, child and user

WordPress never reads one theme.json. WP_Theme_JSON_Resolver::get_merged_data() builds the result from four origins, each overriding the previous one, and each passes through a filter first:

flowchart LR core["Core: wp-includes/theme.json"] --> fd(["wp_theme_json_data_default"]) fd --> o1["origin: default"] blocks["block.json of registered blocks"] --> fb(["wp_theme_json_data_blocks"]) fb --> o2["origin: blocks"] supports["add_theme_support() values"] --> o3 parent["Parent theme.json: lumen"] --> o3["origin: theme"] child["Child theme.json: lumen-child"] --> ft(["wp_theme_json_data_theme"]) ft --> o3 user["wp_global_styles post: Styles panel"] --> fu(["wp_theme_json_data_user"]) fu --> o4["origin: custom (user)"] o1 -- "overridden by" --> o2 o2 -- "overridden by" --> o3 o3 -- "overridden by" --> o4 o4 --> out["merged settings and styles"] out --> api["wp_get_global_settings()"] out --> css["wp_get_global_stylesheet()"] out --> ed["block editor settings"]

Inside the theme origin, theme supports (add_theme_support( 'editor-color-palette' ) and similar, mostly used by classic themes) are the lowest level, then the parent’s theme.json, then the child’s. The user origin is the wp_global_styles post of the active theme, written by the Styles panel; choosing a style variation copies that variation’s JSON into it.

Merging rules that matter in practice:

  • Objects merge key by key: a child that sets only layout.wideSize keeps the parent’s contentSize.

  • Preset lists replace each other per origin: a child’s palette replaces the parent’s whole palette, it does not add to it. To add one colour, the child repeats the parent’s list.

  • Presets are kept per origin, not flattened: wp_get_global_settings( array( 'color', 'palette' ) ) returns default, theme and custom lists, and the editor shows them as separate groups ("Default", "Theme", "Custom").

  • Block-level values stay block-level: the parent’s settings.blocks survive in the child unless the child overrides the same block and key.

  • The wp_theme_json_data_theme filter receives the child’s data when a child theme is active; the parent’s file is merged underneath afterwards.

The lumen-child theme overrides three colours and the wide size:

{
	"$schema": "https://schemas.wp.org/wp/7.1/theme.json",
	"version": 3,
	"settings": {
		"color": {
			"palette": [
				{ "slug": "paper", "name": "Paper", "color": "#ffffff" },
				{ "slug": "ink", "name": "Ink", "color": "#111111" },
				{ "slug": "lumen-red", "name": "Lumen red", "color": "#8c1d17" }
			]
		},
		"layout": {
			"wideSize": "1280px"
		}
	}
}

With the child active and one colour saved in the Styles panel (the sale colour, saved through the same /wp/v2/global-styles/<id> endpoint the editor uses), a short script prints each origin:

<?php
// layers.php: prints the palette per origin and the merged layout widths.
$palette = wp_get_global_settings( array( 'color', 'palette' ) );
foreach ( $palette as $origin => $colors ) {
	$pairs = array_map(
		static function ( $c ) {
			return $c['slug'] . '=' . $c['color'];
		},
		$colors
	);
	printf( "%-7s %s\n", $origin, implode( ' ', $pairs ) );
}
printf(
	"contentSize %s, wideSize %s\n",
	wp_get_global_settings( array( 'layout', 'contentSize' ) ),
	wp_get_global_settings( array( 'layout', 'wideSize' ) )
);
$ wp eval-file layers.php
default black=#000000 cyan-bluish-gray=#abb8c3 white=#ffffff pale-pink=#f78da7 vivid-red=#cf2e2e ...
theme   paper=#ffffff ink=#111111 lumen-red=#8c1d17
custom  sale=#d4a017
contentSize 680px, wideSize 1280px

The parent’s mist and slate are gone (the child’s list replaced them), contentSize still comes from the parent, and the user colour is a separate group. The parent’s Button palette is also still in force, so inside buttons --wp--preset--color--lumen-red keeps the parent’s #a3271f while the rest of the page uses the child’s #8c1d17. A child theme that changes brand colours must therefore repeat the per-block palettes too. Child theme mechanics are covered in Child Themes.

Changing settings from PHP

A plugin can change any layer with the four filters. Each receives a WP_Theme_JSON_Data object; update_with() merges a partial theme.json array (with its version) into it. The must-use plugin below removes the free colour picker and custom font sizes on every theme, which a publisher may want so that editors stay within the brand:

<?php
/**
 * Plugin Name: Lumen theme.json guardrails
 * Description: Locks colour and font-size choices to the theme presets on every theme.
 */
add_filter(
	'wp_theme_json_data_theme',
	static function ( WP_Theme_JSON_Data $theme_json ): WP_Theme_JSON_Data {
		return $theme_json->update_with(
			array(
				'version'  => 3,
				'settings' => array(
					'color'      => array( 'custom' => false ),
					'typography' => array( 'customFontSize' => false ),
				),
			)
		);
	}
);

Saved as wp-content/mu-plugins/lumen-theme-json-guardrails.php, it makes wp_get_global_settings( array( 'color', 'custom' ) ) return false. A user-layer value would still win over the theme layer, but the Styles panel cannot save a setting whose control it no longer shows. Results are cached per request; code that changes settings after wp_get_global_settings() has run must call wp_clean_theme_json_cache().

Version 2 to version 3

Version 3 changed defaults, not syntax. Upgrading means changing "version": 2 to 3 and checking three things:

Behaviour Version 2 Version 3

Theme fontSizes with core slugs (small, medium, large, x-large)

Override core’s sizes

Ignored while defaultFontSizes is true (the default); core’s sizes win

Theme spacingSizes with core slugs (20 to 80)

Override core’s sizes

Ignored while defaultSpacingSizes is true (the default)

Theme spacingSizes and spacingScale together

spacingSizes replaces the scale

Merged

The migration that WordPress applies to version 2 files in memory sets defaultFontSizes and defaultSpacingSizes to false when the theme defines font sizes or spacing sizes, which preserves version 2 behaviour; a file that says "version": 3 gets no such help. The difference shows with one fragment loaded once as version 2 and once as version 3:

{
	"version": 2,
	"settings": {
		"typography": {
			"fontSizes": [
				{ "slug": "small", "name": "Small", "size": "0.875rem", "fluid": false }
			]
		},
		"spacing": {
			"spacingSizes": [
				{ "slug": "40", "name": "M", "size": "2rem" }
			]
		}
	}
}
<?php
// probe.php: prints the effective flags and the variables for the colliding slugs.
$typography = wp_get_global_settings( array( 'typography' ) );
$spacing    = wp_get_global_settings( array( 'spacing' ) );
printf(
	"defaultFontSizes=%s defaultSpacingSizes=%s\n",
	var_export( $typography['defaultFontSizes'], true ),
	var_export( $spacing['defaultSpacingSizes'], true )
);
$css = wp_get_global_stylesheet( array( 'variables' ) );
preg_match_all( '/--wp--preset--(font-size--small|spacing--40):[^;]+/', $css, $m );
echo implode( "\n", $m[0] ), "\n";
== "version": 2
defaultFontSizes=false defaultSpacingSizes=false
--wp--preset--font-size--small: 0.875rem
--wp--preset--spacing--40: 2rem
== "version": 3
defaultFontSizes=true defaultSpacingSizes=true
--wp--preset--font-size--small: 13px
--wp--preset--spacing--40: 1rem

With version 3 the theme’s small size is dropped from the theme list altogether, so the editor shows core’s 13px "Small" and every has-small-font-size paragraph shrinks. The fix is the one Lumen uses: set defaultFontSizes and defaultSpacingSizes to false explicitly (or use slugs that do not collide). Style variation files in styles/ have their own version and are migrated separately, so they must be upgraded together with theme.json. The other preset types (palette, gradients, duotone, shadows, aspect ratios) already behaved this way in version 2 through their own default* switches.

The generated CSS variables

wp_get_global_stylesheet() returns the CSS that WordPress prints in the global-styles-inline-css style tag and the editor iframe. Its types argument selects parts: variables (custom properties), presets (the has-* classes), styles and base-layout-styles. With lumen active:

wp theme activate lumen
wp eval 'echo wp_get_global_stylesheet( array( "variables" ) );' | sed 's/;/;\n/g; s/{/{\n/g; s/}/}\n/g'

Source: WP-CLI: wp eval

:root{
--wp--preset--aspect-ratio--square: 1;
/* ...the other six core aspect ratios... */
--wp--preset--aspect-ratio--book-cover: 2/3;
--wp--preset--aspect-ratio--wide: 16/9;
--wp--preset--color--black: #000000;
/* ...the other eleven core colours... */
--wp--preset--color--paper: #fbf8f1;
--wp--preset--color--ink: #1f1d1a;
--wp--preset--color--lumen-red: #a3271f;
--wp--preset--color--lumen-red-dark: #7a1d17;
--wp--preset--color--mist: #e9e4d8;
--wp--preset--color--slate: #4a4740;
/* ...twelve core gradients... */
--wp--preset--gradient--paper-to-mist: linear-gradient(180deg, #fbf8f1 0%, #e9e4d8 100%);
--wp--preset--font-size--small: 0.875rem;
--wp--preset--font-size--medium: clamp(1rem, 1rem + ((1vw - 0.225rem) * 0.256), 1.125rem);
--wp--preset--font-size--large: clamp(1.375rem, 1.375rem + ((1vw - 0.225rem) * 0.769), 1.75rem);
--wp--preset--font-size--x-large: clamp(1.875rem, 1.875rem + ((1vw - 0.225rem) * 1.795), 2.75rem);
--wp--preset--font-family--source-serif: "Source Serif 4", Georgia, serif;
--wp--preset--font-family--system-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--wp--preset--spacing--20: 0.5rem;
--wp--preset--spacing--30: 1rem;
--wp--preset--spacing--40: clamp(1.25rem, 3vw, 2rem);
--wp--preset--spacing--50: clamp(2rem, 5vw, 3.5rem);
--wp--preset--spacing--60: clamp(3rem, 8vw, 6rem);
--wp--preset--spacing--70: 3.38rem;
--wp--preset--spacing--80: 5.06rem;
--wp--preset--shadow--natural: 6px 6px 9px rgba(0, 0, 0, 0.2);
/* ...the other four core shadows... */
--wp--preset--shadow--card: 0 1px 3px rgb(31 29 26 / 0.15);
--wp--preset--shadow--lifted: 0 8px 24px rgb(31 29 26 / 0.2);
--wp--preset--border-radius--small: 2px;
--wp--preset--border-radius--medium: 6px;
--wp--preset--border-radius--pill: 9999px;
--wp--preset--dimension--cover-s: 120px;
--wp--preset--dimension--cover-m: 200px;
--wp--preset--dimension--measure: 65ch;
--wp--custom--line-height--body: 1.6;
--wp--custom--line-height--heading: 1.2;
--wp--custom--book-card--cover-width: var(--wp--preset--dimension--cover-m);
--wp--custom--book-card--gap: var(--wp--preset--spacing--30);
}
.wp-block-button .wp-block-button__link{
--wp--preset--color--lumen-red: #a3271f;
--wp--preset--color--ink: #1f1d1a;
}
.wp-block-button{
/* core's 25%, 50%, 75% and 100% dimension presets */
}
.wp-block-code{
--wp--preset--font-size--small: 0.8125rem;
}

What the output shows:

  • The naming pattern is --wp--preset--{type}--{slug} for presets and --wp--custom--{path} for custom values. Theme CSS and block stylesheets should reference these variables, never the raw values.

  • Hidden defaults are still printed. defaultPalette: false removes core’s colours from the pickers, not from the CSS, so old content that uses vivid-red keeps rendering.

  • Spacing 70 and 80 come from core: with defaultSpacingSizes: false the theme’s 20 to 60 win, but core’s sizes for slugs the theme does not define are still printed. The editor offers only the theme’s five.

  • Per-block presets are re-declared on the block’s selector, which is how one slug can mean two values.

  • Duotone presets do not appear: their filters are output only for blocks that use them.

The presets part contains the utility classes, such as .has-lumen-red-background-color{background-color: var(--wp--preset--color--lumen-red) !important;} and, for the Button palette, the same classes wrapped in :where(.wp-block-button .wp-block-button__link). The layout values appear as --wp--style--global--content-size: 680px and --wp--style--global--wide-size: 1140px in the styles part, and useRootPaddingAwareAlignments produces the .has-global-padding rules there.

The merged settings themselves come from wp_get_global_settings(), with an optional path and a block_name or origin context, as the scripts in the layers and migration sections show.

The site used for these results is the standard Compose stack (MariaDB 11.8 and the wordpress:cli-2.12.0-php8.4 image) from Local Development Environments, with the theme folder copied into wp-content/themes/. Settings are read from the files on every request (with a per-request cache), so an edit to theme.json shows on the next page load without reactivating the theme. On a production server with a persistent object cache, the theme.json cache is cleared when the theme is switched (switch_theme calls wp_clean_theme_json_cache()); after deploying a changed theme.json without a switch, run wp cache flush.

Security notes

  • Theme files are trusted code: whatever theme.json declares is printed into CSS without sanitizing, like the theme’s own stylesheet. Only install themes from trusted sources.

  • The user layer is not trusted. When a user without unfiltered_html saves global styles, the JSON passes through wp_filter_global_styles_post(), which removes unsafe values with WP_Theme_JSON::remove_insecure_properties() before it is stored.

  • viewport values are restricted to px, em and rem because they are interpolated into media queries; other values are discarded rather than escaped.

  • Feature switches are interface limits, not permissions. A user with edit_posts can still put any value in block markup through the code editor or the REST API; to restrict what content may contain, use capabilities, block locking and allowed_block_types_all, not theme.json.

What older books and tutorials get wrong

Older advice Current state (7.1)

Design settings are configured with add_theme_support() calls (editor-color-palette, editor-font-sizes, custom-spacing) and the Customizer; the books on classic themes and on Customizer-based design do not mention theme.json at all

theme.json is the configuration file of block and hybrid themes. Theme supports still work, as the lowest level of the theme origin, but newer controls (spacing presets, shadows, radius presets, dimension presets, viewports) exist only in theme.json.

Full site editing and its configuration date from WordPress 5.4 to 5.6

theme.json version 1 arrived in 5.8, version 2 and block themes in 5.9, version 3 in 6.6.

Site-wide design is "still maturing" and needs a commercial theme or page builder

Presets, fluid type, responsive breakpoints and per-block settings cover what builders were used for; the trade-offs are discussed in Page Builders and Commercial Themes.

Tutorials written for version 2: a theme defines small or large font sizes and they override core’s

In version 3 they are ignored until defaultFontSizes is false; the same for spacing slugs and defaultSpacingSizes.

spacingSizes replaces the generated scale

In version 3 the two are merged.

"spacingScale": { "steps": 0 } to turn the scale off

Works at runtime but fails schema validation; set defaultSpacingSizes to false and leave the scale out.

$schema pointing to trunk

Pin the schema of the oldest supported WordPress version, such as wp/7.1; trunk lists keys that 7.1 does not support.

Breakpoints in theme CSS with hard-coded media queries

7.1 has settings.viewport and @mobile/@tablet styles; custom media queries remain possible but do not move with the theme’s breakpoints.

Common errors and pitfalls

Symptom Cause and fix

No setting in theme.json has any effect

The file has no version key (the whole file is then replaced by an empty document), or it is not valid JSON (WordPress ignores it). Validate it against the schema.

Theme font sizes or spacing sizes are ignored after upgrading to version 3

The slugs collide with core’s. Set defaultFontSizes or defaultSpacingSizes to false.

Core colours still appear in the CSS after defaultPalette: false

Expected: the variables stay for existing content; only the pickers hide them.

The child theme lost colours of the parent

Preset lists replace each other; repeat the parent’s entries in the child.

Buttons keep the old brand colour in a child theme

The parent’s settings.blocks.core/button palette re-declares the variables on the button; override it in the child too.

A top-level setting does not reach one block

Core or a block’s block.json sets the same key at block level (for example the Image lightbox). Set it under settings.blocks for that block.

@mobile styles apply at the wrong width

settings.viewport uses an unsupported unit, or tablet is not larger than mobile; check wp_get_global_settings( array( 'viewport' ) ).

A setting from a tutorial is accepted by the editor’s autocompletion but does nothing

The $schema points to trunk and the key is newer than the installed WordPress; pin the schema.

A change to theme.json is not visible

The user layer overrides it (reset Styles), a persistent object cache holds the old data (wp cache flush), or a style variation chosen in Styles has its own values.