All WordPress HTML Templates Forms & Webhooks AI & Tools
WordPress

theme.json Explained: The File That Controls Your Whole Block Theme

theme.json explained by developers who ship block themes: settings vs styles, version 3 changes, specificity, section styles and the gotchas that break

A real working moment illustrating the theme of an article about theme.json. Wide 16:9 banner, one strong focal point, magazine editorial quality, authentic and unstaged.

A client site we took over last year had a 2,400 line style.css fighting the block editor on every screen. Buttons styled three different ways. Font sizes hard-coded in px inside block attributes because the theme never registered a type scale. The fix wasn’t more CSS. It was 180 lines of theme.json and deleting most of the stylesheet.

That ratio is typical. In a well-built block theme, theme.json isn’t a config file that sits beside your CSS. It’s the thing that generates most of your CSS, drives what the editor UI offers, and decides whether a client can break the design with two clicks in the Styles panel.

If you’ve read the listicles that show you a colour palette and stop, this is the rest: the parts that bite on real builds.

Key Takeaways

  • theme.json compiles to a single global-styles-inline-css block in <head>: presets become custom properties like --wp--preset--color--brand and utility classes like .has-brand-background-color, so registering a palette is also authoring your design tokens.
  • settings controls what the editor offers, styles controls what the site looks like. Most broken client sites are a settings problem: too many knobs exposed, core defaults left switched on.
  • Version 3 (WordPress 6.6+) changed opt-out behaviour: you now need defaultFontSizes: false and defaultSpacingSizes: false explicitly, otherwise core’s sizes sit alongside yours in the UI.
  • Anything a user saves in the Site Editor lands in a wpglobalstyles post and beats your theme.json permanently. This is the number one reason “my theme.json change isn’t working”.
  • Core wraps block-level global styles in :where() to keep specificity near zero, which means your own stylesheet usually wins on source order. Confirm in devtools, don’t assume.

What theme.json actually does at runtime

On every front-end request, WPThemeJSON_Resolver merges four layers in order: core defaults, your theme’s theme.json, block-level data registered by plugins, then user data saved from the Site Editor. The merged result is serialised into CSS and printed inline. Open view-source on any block theme and search for global-styles-inline-css. That blob is your file, compiled.

Three kinds of output come out of it:

  • Custom properties from presets: --wp--preset--color--accent, --wp--preset--font-size--large, --wp--preset--spacing--50, plus anything under settings.custom as --wp--custom--*.
  • Utility classes: every colour preset generates .has-{slug}-color and .has-{slug}-background-color; font sizes generate .has-{slug}-font-size. These are what the editor applies when someone picks a swatch.
  • Element and block rules from the styles object, scoped to selectors core knows about.

The practical consequence: your palette slugs are a public API. Rename accent to brand after launch and every post that used .has-accent-color silently loses its colour. That migration means a database search and replace. Pick slugs you can live with, keep them semantic (ink, paper, accent, muted) and never name them after the hex value.

A real working moment that fits an article section about

settings versus styles, and why settings is where builds go wrong

Developers coming from classic themes obsess over styles because that’s the part that looks like CSS. The section that decides whether a site survives a year of client editing is settings.

Leave color.custom at its default of true and every editor gets a full colour picker. Within a month you’ll have #1a1a1b, #1b1b1b and #222 in production. Same with typography.customFontSize and the default spacing scale. Shutting the doors is the whole job:

{
  "$schema": "https://schemas.wp.org/trunk/theme.json",
  "version": 3,
  "settings": {
    "appearanceTools": true,
    "useRootPaddingAwareAlignments": true,
    "layout": { "contentSize": "44rem", "wideSize": "80rem" },
    "color": {
      "defaultPalette": false,
      "defaultGradients": false,
      "defaultDuotone": false,
      "custom": false,
      "customGradient": false,
      "palette": [
        { "slug": "paper", "name": "Paper", "color": "#ffffff" },
        { "slug": "ink", "name": "Ink", "color": "#141718" },
        { "slug": "muted", "name": "Muted", "color": "#6b7280" },
        { "slug": "accent", "name": "Accent", "color": "#0b63f6" }
      ]
    },
    "typography": {
      "defaultFontSizes": false,
      "customFontSize": false,
      "fluid": true,
      "fontSizes": [
        { "slug": "small", "name": "Small", "size": "0.875rem" },
        { "slug": "medium", "name": "Medium", "size": "1rem" },
        { "slug": "large", "name": "Large", "size": "1.375rem",
          "fluid": { "min": "1.25rem", "max": "1.5rem" } },
        { "slug": "x-large", "name": "XL", "size": "2.5rem",
          "fluid": { "min": "1.875rem", "max": "3rem" } }
      ]
    },
    "spacing": {
      "defaultSpacingSizes": false,
      "units": [ "rem", "%", "vw" ],
      "spacingSizes": [
        { "slug": "30", "name": "S", "size": "clamp(1rem, 2vw, 1.5rem)" },
        { "slug": "50", "name": "M", "size": "clamp(2rem, 4vw, 3rem)" },
        { "slug": "70", "name": "L", "size": "clamp(3.5rem, 7vw, 6rem)" }
      ]
    },
    "custom": {
      "shadow": { "card": "0 1px 2px rgb(20 23 24 / 0.08)" },
      "lineHeight": { "tight": 1.12 }
    },
    "blocks": {
      "core/button": { "border": { "radius": true }, "typography": { "customFontSize": false } },
      "core/paragraph": { "color": { "background": false } }
    }
  }
}

appearanceTools: true is the shortcut that turns on border, link colour, block gap, padding, margin, line height and a handful of others in one line. Use it, then switch individual things back off per block under settings.blocks. Note that custom.lineHeight.tight becomes --wp--custom--line-height--tight: camelCase keys get kebab-cased, and nesting becomes double hyphens.

Two keys earn special mention. useRootPaddingAwareAlignments lets a full-width block break out of your root padding instead of being inset by it. That’s the behaviour you want on nearly every site, but it has to be paired with styles.spacing.padding set on the root. And defaultSpacingSizes: false plus defaultFontSizes: false are version 3 requirements. Under version 2, supplying your own array replaced core’s. Under version 3 it doesn’t. Plenty of themes upgraded the version number without adding those two lines, which is why their clients see seven font sizes instead of four.

Writing the styles layer without reinventing CSS

The styles object has three useful scopes: root, elements, and blocks. Elements are the cross-cutting ones core understands: link, button, heading, h1 through h6, caption and cite.

{
  "styles": {
    "color": { "background": "var(--wp--preset--color--paper)", "text": "var(--wp--preset--color--ink)" },
    "typography": {
      "fontFamily": "var(--wp--preset--font-family--body)",
      "fontSize": "var(--wp--preset--font-size--medium)",
      "lineHeight": "1.6"
    },
    "spacing": {
      "blockGap": "var(--wp--preset--spacing--50)",
      "padding": { "left": "var(--wp--preset--spacing--30)", "right": "var(--wp--preset--spacing--30)" }
    },
    "elements": {
      "heading": {
        "typography": { "fontWeight": "600", "lineHeight": "var(--wp--custom--line-height--tight)" }
      },
      "link": {
        "color": { "text": "var(--wp--preset--color--accent)" },
        "typography": { "textDecoration": "underline" },
        ":hover": { "typography": { "textDecoration": "none" } }
      },
      "button": {
        "border": { "radius": "0.375rem" },
        "spacing": { "padding": { "top": "0.75rem", "bottom": "0.75rem", "left": "1.25rem", "right": "1.25rem" } },
        ":focus": { "outline": { "width": "2px", "style": "solid", "color": "var(--wp--preset--color--ink)" } }
      }
    },
    "blocks": {
      "core/pullquote": {
        "css": "border-top: 2px solid currentColor; border-bottom: 2px solid currentColor; & blockquote { margin: 0; } & cite { font-style: normal; }"
      }
    }
  }
}

The css property (WordPress 6.2 onwards) is a genuine escape hatch. Declarations without a selector apply to the block root; anything after & nests. Use it for the awkward 10 percent the schema doesn’t model, like inner element tweaks on quote blocks. Don’t use it as a dumping ground for 400 lines of layout CSS. Pseudo-state support in the schema is limited to a fixed list (:hover, :focus, :active, :visited, :link, :any-link), so anything else goes in css or your stylesheet.

For typography specifically, fluid: true turns every preset with a fluid object into a clamp() expression generated from your min and max. It’s convenient, and it’s also where scales go soft: the generated ratio between steps is not the ratio you designed. If you care about the numbers, write the clamp() yourself in the size field and leave fluid off. We go into the reasoning in Web Typography for Developers.

Specificity: why your CSS wins, loses, or randomly does both

Since WordPress 6.1, core wraps block-level global styles in :where() so they contribute almost nothing to specificity. Root styles come out on a low-specificity selector too. The intent is that theme and plugin CSS can override global styles without !important. In practice the global stylesheet prints early in <head>, so an equal-specificity rule in your own style.css wins on source order.

Where this trips people is the opposite direction. If you write theme.json styles expecting them to beat a block’s own stylesheet or a plugin’s CSS, they often won’t. The debugging move is always the same: open devtools, find the computed value, click through to the winning rule and read its selector. Three minutes of that beats an hour of guessing. If the culprit turns out to be a plugin rather than your theme, Query Monitor and the enqueue list will tell you who put it there.

One rule of thumb that has held up across dozens of builds: put tokens and anything the editor should surface in theme.json, put component-level layout in a stylesheet, and never style the same property in both places. Duplication is how you end up with a site where changing the accent colour fixes buttons but not links.

Style variations and section styles

Drop a JSON file in /styles/ and it shows up as a selectable variation in the Styles panel. Since 6.6 these can be partial: a file containing only settings.color appears in the colour browser, which is how you ship four palettes without duplicating your whole config four times.

The more useful addition is block style variations with a blockTypes key, often called section styles. One file, one reusable inverted section, applied per block:

{
  "$schema": "https://schemas.wp.org/trunk/theme.json",
  "version": 3,
  "title": "Inverted",
  "slug": "inverted",
  "blockTypes": [ "core/group", "core/columns", "core/cover" ],
  "styles": {
    "color": {
      "background": "var(--wp--preset--color--ink)",
      "text": "var(--wp--preset--color--paper)"
    },
    "blocks": {
      "core/heading": { "color": { "text": "var(--wp--preset--color--paper)" } }
    },
    "elements": {
      "link": { "color": { "text": "var(--wp--preset--color--paper)" } },
      "button": {
        "color": { "background": "var(--wp--preset--color--paper)", "text": "var(--wp--preset--color--ink)" }
      }
    }
  }
}

Save that as styles/inverted.json. Now an editor selects a Group block, picks “Inverted” from the Styles tab, and the headings, links and buttons inside it all invert. Before this existed we shipped the same thing as a custom block style plus a .is-style-inverted block in the stylesheet with a dozen descendant overrides. This is the single biggest quality-of-life change in block themes since templates went to HTML, and it’s a large part of why we no longer argue with clients about “can we have a dark band here”.

The failures we hit on nearly every build

User global styles silently win

When anyone clicks Save in the Site Editor’s Styles panel, WordPress writes a wpglobalstyles post for that theme. That layer sits above your theme.json forever. You change a colour in the file, deploy, nothing happens, and you start doubting your merge. Check it:

# Find the saved global styles post for the active theme
wp post list --posttype=wpglobalstyles --fields=ID,postname,post_title

Read what's actually overriding you

wp post get 42 --field=post_content | jq .

Nuclear option. This discards every Styles customisation the client made.

wp post delete 42 --force

Tell clients this up front, and during handover show them that Styles panel changes are theirs, not yours. On sites with multiple editors we usually remove the Styles entry point from the Site Editor for non-admin roles. If you’re running this across a network, the same posts exist per site, which is one more item for the list in WordPress Multisite: The Questions to Answer Before You Commit.

Caching during development

Merged theme.json data is cached per request and, with a persistent object cache, between requests. If edits aren’t showing, flush the cache (wp cache flush) before you rewrite the file. Running with WP_DEBUG enabled locally avoids most of it.

Pinning the schema

Using https://schemas.wp.org/trunk/theme.json means your editor validates against trunk, which will happily autocomplete keys that don’t exist in the WordPress version your client is running. Pin it: https://schemas.wp.org/wp/6.7/theme.json. Costs nothing, prevents a confusing afternoon.

blockGap is not margin

spacing.blockGap maps to flex/grid gap on layout blocks and to margin fallbacks elsewhere depending on the block’s layout support. Setting it globally and then also setting vertical margins on headings double-spaces everything. Pick gap as your vertical rhythm mechanism and set element margins to 0, or do the opposite, but don’t mix.

Changing theme.json from PHP

For anything dynamic, such as a brand colour stored in an option or an ACF field, use the filters added in 6.1. There are four, one per layer: wpthemejsondatadefault, wpthemejsondatablocks, wpthemejsondatatheme and wpthemejsondatauser.

addfilter( 'wpthemejsondatatheme', function ( $themejson ) {
	$brand = getoption( 'clientbrand_color' );

	if ( ! $brand || ! preg_match( '/^#[0-9a-f]{6}$/i', $brand ) ) {
		return $theme_json; // Never pass unvalidated input into generated CSS.
	}

	return $themejson->updatewith( array(
		'version'  => 3,
		'settings' => array(
			'color' => array(
				'palette' => array(
					array( 'slug' => 'accent', 'name' => 'Accent', 'color' => $brand ),
				),
			),
		),
	) );
} );

update_with() does a deep merge, so you only pass the branch you’re changing. One caution: this runs on the front end and in the editor, so an expensive lookup here costs you on every page load. Cache it in a transient if it touches an third-party API.

If you’d rather not hand-author any of this, both CanvasWP and modern multipurpose themes ship a pre-built token system you configure through the UI. That’s a reasonable trade for marketing sites, and a bad one if the design is bespoke, for the reasons we set out in Block Themes vs Classic Themes.

Frequently Asked Questions

Do I need theme.json in a classic theme?

You don’t need it, but adding one is usually worth it. A classic theme with a theme.json gets the preset system, editor colour and type controls, and the ability to switch off core’s default palette, without converting any templates. You won’t get template editing or the Site Editor, since those need templates/index.html.

Should I upgrade from version 2 to version 3?

Yes, if your minimum supported WordPress is 6.6 or later. Change "version": 2 to 3 and add defaultFontSizes: false and defaultSpacingSizes: false if you were relying on your arrays replacing core’s. Skip that step and editors get both sets of sizes in the sidebar, which looks like a bug and is the most common post-upgrade complaint.

Why isn’t my theme.json change showing on the front end?

In order of likelihood: someone saved changes in the Site Editor Styles panel and that user layer is overriding you, an object cache is serving stale merged data, or another stylesheet is winning on specificity or source order. Check the wpglobalstyles post first, flush the cache second, open devtools third.

Can I put media queries in theme.json?

Not in the schema itself, which has no concept of breakpoints. You can put them inside a styles.css string or a per-block css value, since those are passed through as raw CSS. For anything substantial, a real stylesheet is easier to read and easier to diff.

How big does the generated CSS get?

A tight theme with four colours, four font sizes and three spacing steps produces a few kilobytes inline. It grows fast if you keep core’s defaults on, because every unused preset still emits custom properties and utility classes. Switching off defaultPalette, defaultGradients, defaultDuotone and the default size scales typically cuts the inline block by more than half.

Where to start

If you’re building new, write settings before you write a single line of CSS. Decide your palette slugs, your type scale, your spacing steps, and which controls editors are allowed to touch. The styles follow easily once the tokens exist.

If you’ve inherited a mess, work in the other order: open the generated global stylesheet, list every token the theme actually uses, then delete the stylesheet rules that duplicate them. On the takeover site we mentioned at the top, that process removed about 1,700 lines of CSS and turned a two-day branding change into a four-line edit. Do that once and you’ll stop treating theme.json as configuration.

block theme styles specificity theme.json theme.json default font sizes false theme.json section styles blockTypes theme.json settings vs styles wordpress theme json version 3 wp_global_styles overriding theme.json wp_theme_json_data_theme filter example