Block themes and theme.json
theme.json as the Source of Design Tokens
How theme.json turns design decisions into CSS custom properties, what its four sections control, and when a preset beats an inline style.
A design system that lives in a stylesheet can only be read by whoever opens the
stylesheet. A plugin author guessing at your spacing scale writes padding: 24px
and hopes. The next theme change makes that guess wrong in a way nobody notices until a
screenshot looks off.
theme.json is the file that fixes this by making the design decisions
data. WordPress reads it, turns each declared value into a named CSS custom property, and
prints those properties into the page. Your CSS, a plugin's CSS and the editor's own
generated styles all read the same names. Get the file right and the whole page agrees on
what "large text" means.
Four top-level sections, and only two of them are common
A theme.json file accepts settings, styles,
templateParts and customTemplates, plus version,
$schema, title, description, slug,
blockTypes and patterns. Most themes only ever write the first
two.
| Section | What it declares | Reach |
|---|---|---|
settings |
The vocabulary the editor may use: palettes, font sizes, spacing steps, layout widths, and which controls appear at all. | Produces custom properties and preset classes. |
styles |
What the theme actually looks like: colours, typography, spacing, borders, and the same properties per block or per element. | Produces plain CSS rules and style classes. |
templateParts |
Titles and areas for files in parts/, so the Site Editor lists them meaningfully. |
Editor metadata only. No CSS. |
customTemplates |
Titles and allowed post types for files in templates/ that are not part of the hierarchy. |
Editor metadata only, no CSS. |
The split between the first two is the one that matters. settings answers
"what is on the menu", styles answers "what did we order". A colour listed
under settings.color.palette is available to pick and generates a variable.
A colour set under styles.color.background is applied to the body. Declaring
a palette without setting any colour gives you a working set of tokens and a site that
looks like the default.
settings produce custom properties, styles produce rules
This is the mechanism the rest of the course depends on, so it is worth seeing once in
full. Every entry in a preset list becomes a CSS custom property whose name is built from
three parts: the --wp--preset-- prefix, the family, and the slug you chose.
Colour, gradient, duotone, font size, font family and spacing are the families.
--wp--preset--color--{slug}
--wp--preset--gradient--{slug}
--wp--preset--duotone--{slug}
--wp--preset--font-size--{slug}
--wp--preset--font-family--{slug}
--wp--preset--spacing--{slug}
WordPress also prints a class for each preset, which is how the editor's colour picker
applies a choice without writing a custom property into the markup. Pick "Accent" on a
paragraph and the block gets has-accent-color, a
has-text-color marker and the slug-specific rule that goes with them.
Here is a complete theme.json. It is small on purpose: a palette, a spacing scale, a layout width, and a handful of style rules.
{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 3,
"settings": {
"appearanceTools": true,
"color": {
"palette": [
{ "slug": "base", "color": "#ffffff", "name": "Base" },
{ "slug": "contrast", "color": "#14161a", "name": "Contrast" },
{ "slug": "accent", "color": "#1f6f5c", "name": "Accent" }
],
"custom": false
},
"spacing": {
"units": [ "px", "rem", "%", "vw" ],
"spacingScale": {
"operator": "*",
"increment": 1.5,
"steps": 5,
"mediumStep": 1.5,
"unit": "rem"
},
"custom": false
},
"typography": {
"fontSizes": [
{ "slug": "small", "size": "0.875rem", "name": "Small" },
{ "slug": "medium", "size": "1.125rem", "name": "Medium" },
{ "slug": "x-large", "size": "2rem", "name": "Extra Large" }
],
"fluid": true
},
"layout": {
"contentSize": "44rem",
"wideSize": "64rem"
}
},
"styles": {
"color": {
"background": "var(--wp--preset--color--base)",
"text": "var(--wp--preset--color--contrast)"
},
"typography": {
"fontSize": "var(--wp--preset--font-size--medium)",
"lineHeight": "1.6"
},
"spacing": {
"blockGap": "1.5rem"
},
"elements": {
"link": {
"color": { "text": "var(--wp--preset--color--accent)" }
}
}
}
}
Reading it aloud: the editor may use three colours and three font sizes and never a custom value, spacing comes from a generated five-step scale, content is 44rem wide and wide content is 64rem. The body sits on base with contrast text, links take the accent, and paragraphs breathe at 1.5rem.
The CSS that file actually generates
Nothing above produces a selector you wrote. The output is assembled by the style
engine and printed in two places: variables in :root, and style rules
targeting the body and the preset classes. It looks close to this.
:root {
--wp--preset--color--base: #ffffff;
--wp--preset--color--contrast: #14161a;
--wp--preset--color--accent: #1f6f5c;
--wp--preset--font-size--small: 0.875rem;
--wp--preset--font-size--medium: 1.125rem;
--wp--preset--font-size--x-large: 2rem;
--wp--style--global--content-size: 44rem;
--wp--style--global--wide-size: 64rem;
}
body {
background-color: var(--wp--preset--color--base);
color: var(--wp--preset--color--contrast);
font-size: var(--wp--preset--font-size--medium);
line-height: 1.6;
}
a:where(:not(.wp-element-button)) {
color: var(--wp--preset--color--accent);
}
.has-accent-color { color: var(--wp--preset--color--accent) !important; }
.has-base-background-color {
background-color: var(--wp--preset--color--base) !important;
}
The !important is not an accident of my example. The style engine prints
preset classes that way on purpose, so a colour someone picked in the editor beats a theme
rule targeting the same element. It also means a preset class outranks most of your
hand-written CSS. That bites the first time a rule in style.css appears to
be ignored.
The spacing properties are the ones people go looking for and fail to find. The generated scale depends on the operator, increment, step count and unit, so the slugs and the values are both computed. Rather than guess, read the page. On a site running the file above:
curl -s https://example.test/ | grep -o -- '--wp--preset--spacing--[0-9]*: [^;]*'
Whatever that prints is your spacing vocabulary, and it is the only correct source for
it. The fontSize value in styles.typography is written as
var(--wp--preset--font-size--medium) rather than the literal
1.125rem for a specific reason: it means a style variation or a user edit
that redefines the medium font size changes the body text too, without touching the
styles section at all.
Presets are not the same as your own custom properties
The settings.custom key accepts any nested structure you like, and each
leaf becomes a --wp--custom-- property. Use it for values that belong to
your theme and have no editor control, such as a border radius or an easing curve.
Reach for --wp--preset--* when the editor should be able to pick the value,
and --wp--custom--* when only your CSS should read it.
A preset is a choice; an inline style is a one-off
When someone selects a colour from the palette, WordPress stores the slug, and the
markup carries a class. The value stays in theme.json, so changing the hex
there updates every block that chose it. This is the property you want, and it is why the
editor labels the palette as theme colours.
When someone opens the custom colour picker and types a hex code, WordPress stores the value itself and writes it into the markup as an inline style. That block is now detached from the token system. Nothing you do to the palette will move it, and a style variation will leave it exactly where it was, which is either a feature or a bug depending on who picked the colour.
An inline style also loses the searchability that makes a palette maintainable. You cannot query for "every block using the accent colour" when half of them recorded a hex value instead. If you find yourself doing a database search for a colour, the site has already accumulated inline styles that should have been presets.
That is what the two custom switches control.
settings.color.custom: false removes the custom colour picker for colour,
and settings.spacing.custom: false removes the free-form spacing input that
would otherwise let someone type any value they like. Both default to allowing the custom
escape hatch, so setting them to false changes the offer: "use our palette"
instead of "use our palette unless you would rather not".
Disable them when you are handing a site to someone who will not maintain it, and when
a design review would reject an off-palette colour anyway. Leave them on when the site has
real one-off needs, because someone determined will work around a locked picker, and the
workaround will land somewhere you did not design. The neighbours matter too:
settings.color.customDuotone and settings.color.customGradient
gate their own pickers, so switching off custom colours does not lock gradients.
Replace the theme.json in your theme from the previous lesson with the
complete file above, then load the front end and open the page source.
Answer four questions by reading the printed CSS rather than guessing. Which custom
properties are in :root? What slugs did the generated spacing scale
produce, and what are their values? Which rule sets the body font size, and does it
use a literal or a variable? What happens to the link colour if you change the accent
hex in settings and reload?
Then make one change to styles and one to settings: set
styles.typography.fontSize to
var(--wp--preset--font-size--small), and set
settings.color.custom to true. Reload, and write down which
of the two changes is visible in the front end and which is only visible in the
editor.
Finally, add settings.custom.radius.medium with a value of your
choosing, find the property it generates, and use it on a button in
style.css.
Show the solution and the reasoning
The :root block holds one property per palette entry, one per font size,
and the two layout sizes. Your spacing properties are there too, but you have to look
for them, because the slugs and values come from the generated scale rather than from
anything written in the file.
:root {
/* ...colour and font-size properties as above... */
--wp--preset--spacing--20: 0.44rem;
--wp--preset--spacing--30: 0.67rem;
--wp--preset--spacing--40: 1rem;
--wp--preset--spacing--50: 1.5rem;
--wp--preset--spacing--60: 2.25rem;
--wp--preset--spacing--70: 3.38rem;
--wp--style--global--content-size: 44rem;
--wp--style--global--wide-size: 64rem;
--wp--custom--radius--medium: 6px;
}
Treat those exact numbers as illustrative rather than contractual. What matters is
the shape: a fixed set of slug steps with values derived from
mediumStep, operator and increment, and the
guarantee that the slugs exist and are stable once generated. If you need a specific
value at a specific slug, write spacingSizes as an explicit list instead of
spacingScale. Explicit sizes are also what you want when the design hands
you a named scale, because a computed one drifts when someone adjusts the increment.
Generated scales are convenient for a theme you control and a liability for a theme you
distribute.
The body font size rule reads
font-size: var(--wp--preset--font-size--medium), because that is what
styles.typography.fontSize contains. Changing it to reference
small changes the body size on the next request with no other edit, which
is the point of writing variables in styles rather than literals.
The two edits behave differently, and this is the lesson. The
styles.typography.fontSize change is visible on the front end, because it
alters a CSS rule that was already being printed. The
settings.color.custom change is visible only in the editor, because it
controls whether a control is offered, not what any element looks like. A setting is a
permission; a style is a decision. Confusing the two produces the classic complaint that
a theme.json change "did nothing" when it changed the editor precisely as written.
For the custom property, the nesting becomes the name. This declaration:
{
"settings": {
"custom": {
"radius": { "medium": "6px" }
}
}
}
produces --wp--custom--radius--medium, and your CSS consumes it the same
way it consumes a preset:
.wp-element-button {
border-radius: var(--wp--custom--radius--medium, 4px);
}
Keep the fallback. A custom property that only exists because a JSON file is present is a silent failure when that file is replaced by a variation or a user edit that omits the key, and a border radius is not worth a broken layout.
The limit of all this: none of it reaches a block that renders its
own scoped stylesheet. A block that hard-codes its padding in a generated CSS file
ignores your spacing scale, and there is no theme.json key that overrides
another block's output. That is a block to fix, not a token to add.
Progress
Mark this lesson done and it stays marked in this browser.