Skip to content
System nominalUTC --:--:--Articles 080
MODULE 05 Lesson 13 / 18 35 MIN

Design systems in themes

Typography and Spacing Scales in theme.json

How WordPress turns font size and spacing presets into CSS custom properties, how the generated clamp() works, and why too many steps stop scaling.

Open a stylesheet from a theme written in 2018 and you will find the fingerprints of an argument: margin-bottom: 24px in one rule, 32px in the next, 28px ten lines later. None of those numbers is wrong. They are irreconcilable, and every one of them was correct the day it was typed.

A scale is what you get when you decide the relationships between values before you need them. WordPress gives you two places to declare one, settings.typography and settings.spacing, and it converts both into CSS custom properties that the rest of the page reads. The interesting part is not the syntax. It is that the value of a scale comes entirely from what you leave out of it.

A preset is a slug, and the slug becomes the variable name

Every entry you add to a font size or spacing list has a slug. WordPress builds the custom property name from the preset family and that slug, so a size called large becomes --wp--preset--font-size--large, and a spacing step called 50 becomes --wp--preset--spacing--50. You never write the value into markup or into CSS again. You write the name.

Here is a typography and spacing block that I ran against WordPress 7.0.7, on a block theme with a theme.json containing the rest of this lesson.

{
  "$schema": "https://schemas.wp.org/trunk/theme.json",
  "version": 3,
  "settings": {
    "typography": {
      "defaultFontSizes": false,
      "fluid": { "minFontSize": "0.875rem", "maxViewportWidth": "1600px" },
      "fontSizes": [
        { "slug": "small",   "size": "0.875rem", "name": "Small" },
        { "slug": "medium",  "size": "1.125rem", "name": "Medium" },
        { "slug": "large",   "size": "1.75rem",  "name": "Large" },
        { "slug": "x-large", "size": "2.5rem",   "name": "Extra Large" },
        { "slug": "xx-large","size": "3.5rem",   "name": "Extra Extra Large" }
      ]
    }
  }
}

defaultFontSizes: false is not decoration either, and the reason is the slug rather than the size. Core ships four font size presets of its own, and they use the slugs small, medium, large and x-large. Leave the default list enabled and your medium collides with core's medium, which is 20px. When I read the merged settings on the unmodified file, core kept its four presets and my theme kept only xx-large, the one slug that did not collide. Four of my five sizes had been quietly removed, with no warning printed anywhere. Set defaultFontSizes: false and all five survive.

The same trap exists for colour, gradients, duotone and spacing, each with its own flag: defaultPalette, defaultGradients, defaultDuotone, defaultSpacingSizes. The colour one has a surprise worth knowing now, and the next lesson deals with it.

Fluid sizes are a straight line with the ends cut off

Set settings.typography.fluid and WordPress stops writing a fixed font size for each preset. It writes a clamp(). This is the actual output of the file above, read from the :root rules WordPress printed:

:root {
  --wp--preset--font-size--small: 0.875rem;
  --wp--preset--font-size--medium: clamp(0.875rem, 0.875rem + ((1vw - 0.2rem) * 0.313), 1.125rem);
  --wp--preset--font-size--large: clamp(1.119rem, 1.119rem + ((1vw - 0.2rem) * 0.789), 1.75rem);
  --wp--preset--font-size--x-large: clamp(1.502rem, 1.502rem + ((1vw - 0.2rem) * 1.248), 2.5rem);
  --wp--preset--font-size--xx-large: clamp(1.976rem, 1.976rem + ((1vw - 0.2rem) * 1.905), 3.5rem);
}

Read the middle argument of medium as a line. 0.875rem is the value at a 320px viewport, which is the default minViewportWidth. (1vw - 0.2rem) is how far the viewport has moved past that: at 1600px, 1vw is 16px and 0.2rem is 3.2px, so the term is 12.8px. Multiply by 0.313 and you add about 4px, landing on 18px. At a 1600px viewport the middle argument equals 1.125rem, which is why maxViewportWidth is where the growth stops.

So where does 0.313 come from? The maximum viewport comes from settings.layout.wideSize if you set one, otherwise 1600px, unless maxViewportWidth overrides it. The slope is the size change divided by the viewport change, as a percentage:

(18px - 14px) / (1600px - 320px) = 0.003125
0.003125 * 100                          = 0.3125
rounded to three decimals                = 0.313

The minimum font size is the other half of the story, and the default is not zero. WordPress takes your preset size in pixels and multiplies it by min(max(1 - 0.075 * log2(size_px), 0.25), 0.75), then refuses to go below fluid.minFontSize, which defaults to 14px. For large at 28px that factor is 0.639, giving 17.9px, which rounds to the 1.119rem in the output above. For small at 14px, the calculated minimum lands exactly on the floor, so WordPress returns 0.875rem and no clamp at all. That is why there are only four clamp() calls and five variables.

Two consequences follow. A preset at or below the minFontSize floor is never fluid, so a scale whose smallest step is body text will have one static value in it. And the curve is linear in viewport width, not proportional to the font size, so the ratio between two steps changes as the window resizes. The gap between medium and large is 1.6 at 320px and 1.56 at 1600px. That is small enough not to matter here, but it is worth knowing that fluid typography does not preserve your ratios.

Reading the numbers rather than trusting this lesson

Every value above came from wp_get_typography_font_size_value() in WordPress 7.0.7, and the arithmetic was checked against the generated stylesheet. If your version disagrees, the page is right and this page is not. Load the front end and search the source for --wp--preset--font-size.

Spacing has two scales and WordPress uses both

settings.spacing.spacingScale is a generator. You give it five numbers and it produces a list of presets, each with a slug ten apart, centred on 50. settings.spacing.spacingSizes is the list itself, for when you want to name the values.

{
  "settings": {
    "spacing": {
      "defaultSpacingSizes": false,
      "spacingScale": {
        "operator": "*",
        "increment": 1.5,
        "steps": 5,
        "mediumStep": 1,
        "unit": "rem"
      },
      "custom": false
    }
  }
}

With mediumStep 1rem, an increment of 1.5 and multiplication, the steps grow away from the middle:

--wp--preset--spacing--30: 0.44rem;
--wp--preset--spacing--40: 0.67rem;
--wp--preset--spacing--50: 1rem;
--wp--preset--spacing--60: 1.5rem;
--wp--preset--spacing--70: 2.25rem;

Five steps, and the slug of every one of them was computed rather than written. That is the trade: less typing, and you cannot tell from the file what values exist. With operator: "+" the steps are evenly spaced additions, which produces a scale that looks tidy in the middle and cramped at the ends.

The trap is that a partial spacingScale inherits. WordPress merges the keys you supply over the core scale rather than replacing it, so a theme that sets only steps and mediumStep keeps core's increment: 1.5 and operator: "*". When I set five of the five keys to values that happen to match core's defaults, the generated output was byte for byte the core scale, with a 70 and 80 I had not asked for. If the generated scale is not what you expected, print it and read it. Do not reason about it.

Give a distributed theme an explicit spacing list instead

For a theme you are going to distribute, write an explicit list instead. The values are then in the file, a reviewer can read them, and changing a step cannot change four other steps as a side effect.

{
  "settings": {
    "spacing": {
      "defaultSpacingSizes": false,
      "spacingSizes": [
        { "slug": "sm", "size": "0.5rem", "name": "Small" },
        { "slug": "md", "size": "1rem",   "name": "Medium" },
        { "slug": "lg", "size": "2rem",   "name": "Large" },
        { "slug": "xl", "size": "3rem",   "name": "Extra Large" }
      ]
    }
  }
}

This produces --wp--preset--spacing--sm through --wp--preset--spacing--xl. It also leaves core's generated --wp--preset--spacing--20 to --wp--preset--spacing--80 in the stylesheet, which I verified by reading the output rather than assuming the flag removed it. Custom slugs and generated ones coexist, and a reader of your CSS now has two spacing vocabularies to choose from. The way to close that door is not a flag; it is editing the list until nobody reaches for the other one.

Root padding only reaches content when alignments cooperate

Two settings combine, and neither is much use alone. settings.useRootPaddingAwareAlignments: true tells the layout code to add a has-global-padding class to constrained blocks, and styles.spacing.padding supplies the value that class uses.

{
  "settings": { "useRootPaddingAwareAlignments": true },
  "styles": {
    "spacing": {
      "padding": { "left": "var:preset|spacing|lg", "right": "var:preset|spacing|lg" }
    }
  }
}

The var:preset|spacing|lg string is the internal form. WordPress converts it to var(--wp--preset--spacing--lg) on the way out, so your padding stays tied to the token. Here is what that generated:

body {
  --wp--style--root--padding-left: var(--wp--preset--spacing--lg);
  --wp--style--root--padding-right: var(--wp--preset--spacing--lg);
  --wp--style--root--padding-top: 0px;
  --wp--style--root--padding-bottom: 0px;
}

.has-global-padding {
  padding-right: var(--wp--style--root--padding-right);
  padding-left: var(--wp--style--root--padding-left);
}

.has-global-padding > .alignfull {
  margin-right: calc(var(--wp--style--root--padding-right) * -1);
  margin-left: calc(var(--wp--style--root--padding-left) * -1);
}

The negative margin is the point of the feature. A full-width group inside a padded container is pulled back out by exactly the padding it inherited, so a colour band touches both edges of the viewport while the text inside it stays on the same vertical line as everything else. Without useRootPaddingAwareAlignments, a full-width block either stops at the padding or overflows it, and the usual fix people reach for is to wrap things in extra groups until the layout looks right.

I confirmed the class is applied by rendering a constrained group through render_block: it came back with has-global-padding in its class list, and nothing else in the file had to ask for it.

A scale with twelve steps is a list again

The purpose of a scale is to make two values read as clearly different, or as the same value. A spacing scale of 0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3, 3.5, 4 offers a number for everything and a reason for nothing. The designer who wants 1.75rem can have it, and so can the one who wants 1.5rem, and the page ends up with both.

Six to eight steps covers what a site actually needs. Small text spacing, medium component spacing, section spacing, and one or two extremes. On the font side, five is usually enough and four often is: a body size, a small size, a step up for lead paragraphs or subheadings, and two display sizes. If you cannot describe what a step is for in a sentence, the step is not doing work.

There is a second cost to a long scale, which is that the naming stops conveying anything. The numeric slugs make the ratio between steps explicit, which is why WordPress generates them that way. Twelve of them make every choice a lookup.

Lab 35 minutes

In your theme, declare five font size presets and a five-step spacing scale. Turn on settings.typography.fluid with minFontSize: "0.875rem", and set settings.layout.contentSize to 44rem and wideSize to 64rem.

Load the front end and copy every --wp--preset--font-size--* and --wp--preset--spacing--* declaration out of the page source into a file. For each font size, work out the slope yourself from your own wide size and the default 320px minimum viewport, then compare it with what WordPress wrote.

Then make three changes and predict the effect of each before you reload:

Set settings.typography.defaultFontSizes to true. Add settings.useRootPaddingAwareAlignments: true with styles.spacing.padding.left and .right set to a preset, and put an alignfull group inside a constrained group. Finally, add a sixth preset with explicit bounds, { "slug": "hero", "size": "3rem", "name": "Hero", "fluid": { "min": "1.5rem", "max": "3rem" } }.

Report two things: which of the three changes altered the front-end CSS, and what happened to your font size presets after the first one.

Show the solution and the reasoning

The slope check is the part worth doing slowly, because it is the only way to find out whether the wide size is feeding the calculation. With contentSize: 44rem and wideSize: 64rem, and no maxViewportWidth, the maximum viewport is 1024px. For a preset of 1.75rem, the minimum WordPress calculates is round(1.75 * min(max(1 - 0.075 * log2(28), 0.25), 0.75), 3) = 1.119rem, and the slope is (28 - 17.904) / (1024 - 320) * 100 = 1.434. If your output says something other than 1.434, either your wide size is not being read or you have a maxViewportWidth you forgot about.

Setting maxViewportWidth to the same 1024px gives the same number, which is the tell that the layout width is the default source for that value: settings.layout.wideSize when present, 1600px otherwise.

Of the three edits, two change the front end and one does not.

defaultFontSizes: true changes the front end in a way that is easy to misread as nothing happening. Core's four presets return, and because they claim the slugs small, medium, large and x-large, your versions of those four are dropped. The variables still exist and the page still looks plausible, but --wp--preset--font-size--medium is now core's 20px rather than your size, and it is no longer fluid, because core's presets are not marked fluid. Before the change you had five variables, four of them clamped. After it you have five variables again, one of them clamped. The count does not tell you anything; the values do.

The root padding change is visible twice. The has-global-padding class appears in the markup of constrained blocks, and the --wp--style--root--padding-left property appears on body, resolved to your preset. If you set the padding without the setting, the properties are still printed but nothing consumes them, which is the version of this change that looks like a no-op.

The explicit min and max on the sixth preset behave the way most people assume every preset behaves. When both are present, WordPress does not calculate a floor and does not apply the minFontSize limit, so the clamp becomes clamp(1.5rem, 1.5rem + ((1vw - 0.2rem) * 2.062), 3rem). The slope is (48 - 24) / (1024 - 320) * 100, using your wide size as the maximum viewport. This is the escape hatch for display type, where the logarithmic formula that suits body text produces too small a floor. It is also the one place in the system where a preset can end up with a minimum larger than another preset's maximum, so if hero starts at 24px and x-large tops out at 40px, you have two steps of the scale that overlap across most of the viewport range. Overlapping steps are the typographic version of a twelve-step spacing scale.

The wider lesson from the first edit is the one to carry into the next two lessons. Most of what goes wrong with theme.json is a preset that lost a collision or a key that nothing consumes, and both are invisible in the editor. Read the emitted CSS, and treat the file as a request rather than a description.

Progress

Mark this lesson done and it stays marked in this browser.