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.
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.