WordPress theme.json Explained: Settings, Styles and the Cascade
Understand why settings and styles do different jobs in theme.json, and fix block theme edits that render nothing by working through the WordPress cascade.
Published
theme.json is a single configuration file at the root of a block theme that does two separate jobs: settings decides what the block editor offers users, and styles decides what the site actually looks like by default. WordPress parses it, merges it with its own defaults and with anything saved in the Site Editor, and outputs the result as CSS custom properties plus an inline stylesheet on every page.
Confusing those two jobs is why most theme.json edits appear to do nothing. Declaring a color under settings does not apply that color anywhere. It only creates a variable and a swatch in the editor sidebar.
The top-level keys
A minimal file looks like this:
{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 3,
"settings": {},
"styles": {},
"templateParts": [],
"customTemplates": []
}
| Key | What it does |
|---|---|
$schema | Editor autocomplete and validation only. WordPress ignores it. |
version | Tells WordPress which schema shape to parse. Not your theme version. |
settings | Generates CSS variables and controls what the editor exposes. |
styles | Applies actual default styling. |
templateParts | Declares parts in parts/ and which area they belong to. |
customTemplates | Registers page templates in templates/ for the post editor. |
version trips people up because it looks like a theme version number. It is not. It selects a parsing mode, and older WordPress releases silently ignore keys from a schema shape they do not know. If you support older installs, target the version those installs understand rather than assuming newer keys degrade gracefully. Version 3 is current at the time of writing; version 2 is still widely deployed and still parsed.
$schema points at a schema URL hosted by WordPress. Pinning it to trunk gives you the newest hints in your code editor; a version-specific URL is safer if you are deliberately staying on an older schema.
settings: what the editor offers
Everything under settings produces a CSS custom property and, in most cases, a control in the editor. Nothing under settings styles anything.
{
"settings": {
"color": {
"palette": [
{ "slug": "brand", "color": "#1f4ed8", "name": "Brand" },
{ "slug": "ink", "color": "#111827", "name": "Ink" }
],
"custom": false
},
"typography": {
"fontSizes": [
{ "slug": "small", "size": "0.875rem", "name": "Small" },
{ "slug": "large", "size": "1.5rem", "name": "Large" }
]
},
"layout": {
"contentSize": "680px",
"wideSize": "1200px"
}
}
}
That produces variables named from the preset path and slug:
--wp--preset--color--brand
--wp--preset--color--ink
--wp--preset--font-size--small
--wp--preset--font-size--large
It also produces utility classes such as .has-brand-color and .has-brand-background-color, which is how the editor applies a picked swatch to a block.
The "custom": false line is the other half of settings: turning options off. Set it and the arbitrary color picker disappears, leaving users your palette and nothing else. The same pattern works across the board — settings.typography.customFontSize, settings.spacing.customSpacingSize, settings.border, and so on. Nothing under settings is decorative; each key either adds a variable, adds a control, or removes a control.
settings.custom is a free-form bucket for your own variables:
{
"settings": {
"custom": {
"lineHeight": { "body": 1.6 },
"shadow": { "card": "0 1px 3px rgba(0,0,0,0.12)" }
}
}
}
Those become --wp--custom--line-height--body and --wp--custom--shadow--card. Note the transformation: camelCase keys are split into kebab-case, and nesting is joined with double dashes. Getting a variable name wrong is a common reason a value silently falls back.
You can scope settings per block, which is how you allow a color picker on buttons but not on paragraphs:
{
"settings": {
"blocks": {
"core/paragraph": {
"color": { "custom": false, "customGradient": false }
}
}
}
}
styles: what actually renders
styles is where values get applied. It mirrors the editor’s structure: a root level, then elements, then blocks.
{
"styles": {
"color": {
"background": "var(--wp--preset--color--base)",
"text": "var(--wp--preset--color--ink)"
},
"typography": {
"lineHeight": "var(--wp--custom--line-height--body)"
},
"elements": {
"link": {
"color": { "text": "var(--wp--preset--color--brand)" },
":hover": { "typography": { "textDecoration": "none" } }
}
},
"blocks": {
"core/quote": {
"typography": { "fontStyle": "italic" },
"spacing": { "padding": { "left": "var(--wp--preset--spacing--40)" } }
}
}
}
}
Root-level styles land on the body. elements covers HTML primitives that are not blocks — link, button, headings, caption. blocks targets a specific block type by name.
The honest limitation: styles cannot express everything CSS can. There is no way to author your own media queries, no complex selectors, no animations, and only a small set of supported pseudo-class states. Fluid typography and layout-based sizing cover a lot of what people used breakpoints for, but not all of it. Most shipping block themes carry theme.json plus a modest stylesheet, and that is a normal outcome, not a failure.
The settings-versus-styles trap
The single most common support question is some version of “I added my brand color and nothing changed.”
Correct. Adding it to settings.color.palette created --wp--preset--color--brand and a swatch. It applied nothing. To make it the default link color you must also write it under styles:
{
"settings": {
"color": {
"palette": [{ "slug": "brand", "color": "#1f4ed8", "name": "Brand" }]
}
},
"styles": {
"elements": {
"link": { "color": { "text": "var(--wp--preset--color--brand)" } }
}
}
}
Read it as: settings is the box of paint you hand the user, styles is the coat you put on before they arrive.
The cascade, lowest to highest
This is the part that explains a site that “ignores” the theme file. WordPress merges several layers before emitting CSS:
- WordPress core defaults — core ships its own
theme.jsonwith a baseline palette, font sizes and spacing scale. - Parent theme
theme.json - Child theme
theme.json— merged over the parent key by key, not replacing the whole file. - Global Styles saved in the database — what the Site Editor’s Styles panel writes.
- Per-block styles on individual blocks — attributes stored in post content, rendered as inline styles or classes.
Layer 4 is the one that breaks sites. The moment anyone opens the Styles panel and saves, WordPress stores a Global Styles record and that record wins over your file for every property it contains. Editing theme.json afterwards changes nothing visible for those properties, on a site that looks identical after a deploy while the file on disk is obviously different.
The fix is to clear the saved record, not to escalate with !important. Open the Site Editor, go to Styles, open the revisions panel, and reset to the theme’s defaults. Confirm with the client or site owner first — resetting discards their customizations, which is a real cost, not a free action.
The second suspect is caching. WordPress caches the parsed theme.json data rather than re-reading the file on every request, which is exactly what you want in production and exactly what you do not want while developing. Enabling WP_DEBUG, or the theme development mode setting on newer releases, causes WordPress to re-read the file each time. If your edits appear only after a deploy or a cache flush, that is what you are seeing.
templateParts and customTemplates
Both are arrays that describe files already sitting in your theme.
{
"templateParts": [
{ "name": "header", "title": "Header", "area": "header" },
{ "name": "footer", "title": "Footer", "area": "footer" }
],
"customTemplates": [
{ "name": "page-wide", "title": "Wide Page", "postTypes": ["page"] }
]
}
name matches the filename without its extension — parts/header.html, templates/page-wide.html. The area value matters: it decides whether the part is wrapped in a header or footer landmark element and how the editor categorizes it. Leave it off and the part is treated as uncategorized.
customTemplates is what makes a template appear in the template dropdown in the post editor. Without the entry, the file exists but no one can select it.
A quick diagnostic order
When a block theme is not styling the way the file says it should, work through this before touching anything:
- View source and search for the generated global styles block. If your variable is not there, the problem is in
settingsor a slug typo. - If the variable exists but is unused, the problem is a missing
stylesentry. - If both exist but something overrides them, check for a saved Global Styles record.
- Only then look at your own stylesheet, plugins, or a page builder injecting CSS later in the cascade.
FAQ
Questions
What is theme.json in WordPress?
It is a single JSON configuration file at the root of a block theme that controls two things: which design options the block editor exposes to users, and what the default styles are. WordPress reads it, generates CSS custom properties and a stylesheet from it, and merges it with core defaults and any Global Styles the user saved.
What is the difference between settings and styles in theme.json?
Settings defines what is available and generates CSS variables plus editor controls, but applies nothing on its own. Styles applies actual values using those variables. Adding a color to the palette does not change any element until you also reference it under styles or a user picks it in the editor.
Why are my theme.json changes not showing on the front end?
Almost always because Global Styles saved in the database override theme.json. Once anyone touches the Styles panel in the Site Editor, that saved record wins for every property it contains. Open Styles, use the revisions panel to reset to theme defaults, then reload. Caching of parsed theme.json data is the second suspect.
Do I still need a style.css file with theme.json?
Yes. WordPress requires style.css for the theme header comment that supplies the theme name, version and other metadata, so the theme is recognized at all. You can leave it otherwise empty and put your design decisions in theme.json, or keep a small amount of CSS for things theme.json cannot express.
What version number should theme.json use?
Use the highest version your minimum supported WordPress release understands. The version key tells WordPress which schema shape to parse, and older WordPress releases ignore keys they do not recognize. If you target older installs, stay on the version they support rather than assuming newer keys degrade gracefully.
Does theme.json replace CSS entirely?
No. It covers presets, block defaults, element styles, layout widths and spacing well, but it has no concept of media queries you author yourself, complex selectors, animations or pseudo-class states beyond the handful it supports. Most production block themes ship theme.json plus a modest stylesheet for the remainder.