Skip to content
System nominalUTC --:--:--Articles 041
MODULE 01 Lesson 02 / 06 30 MIN

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.

SectionWhat it declaresReach
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.

Lab 30 minutes

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.