Design systems in themes
Colour Palettes and Contrast That Survives Review
How theme.json colour lives in preset variables and editor permissions, and how to compute the WCAG contrast ratio that decides whether a palette is usable.
A designer hands you a green. It looks good in the mockup, it feels calm and professional, and it becomes the accent colour. Six weeks later somebody puts a button on it, the text on the button is your near-black, and a colleague with average eyesight says the label is hard to read. Nothing is broken in the CSS. The colour is not dark enough to carry dark text, and nothing in the design tool said so.
Colour is the one part of a design system with a correctness constraint. A spacing scale that is too shallow looks tight. A type scale with four steps looks flat. A colour pair that fails contrast excludes readers, and it does so silently, because the people who built the theme can read it fine.
A palette entry is a name, a variable and a class
Colour presets follow the same shape as the font sizes from the last lesson: a slug, a value, and a generated custom property. WordPress also generates classes, which is how the editor applies a colour without writing anything into the markup.
{
"settings": {
"color": {
"palette": [
{ "slug": "base", "color": "#ffffff", "name": "Base" },
{ "slug": "contrast", "color": "#14161a", "name": "Contrast" },
{ "slug": "accent", "color": "#1f6f5c", "name": "Accent" },
{ "slug": "accent-soft", "color": "#d8e8e2", "name": "Accent Soft" },
{ "slug": "accent-deep", "color": "#0f3d33", "name": "Accent Deep" }
]
}
}
}
Each entry produces one custom property and four classes. From the front end of a WordPress 7.0.7 site running this file:
:root {
--wp--preset--color--base: #ffffff;
--wp--preset--color--contrast: #14161a;
--wp--preset--color--accent: #1f6f5c;
--wp--preset--color--accent-soft: #d8e8e2;
--wp--preset--color--accent-deep: #0f3d33;
}
.has-accent-color { color: var(--wp--preset--color--accent) !important; }
.has-accent-background-color { background-color: var(--wp--preset--color--accent) !important; }
.has-accent-border-color { border-color: var(--wp--preset--color--accent) !important; }
Those !important declarations are generated, not a style choice by
whoever wrote the theme. They exist so a colour picked in the editor beats a rule in
style.css that targets the same element. The side effect is that preset
classes outrank most hand-written CSS, which is why a border colour in your stylesheet
sometimes appears to be ignored on a block that has a palette colour applied.
defaultPalette controls the picker, not the stylesheet
This is the part of theme.json that most reliably produces a wrong
mental model, so it is worth doing properly. Core ships twelve colours of its own:
black, cyan-bluish-gray, white,
pale-pink, vivid-red and seven more. They live in the default
origin, printed as --wp--preset--color--* in every block theme.
On a clean block theme the generated stylesheet contains twelve colour variables. Add
the five-entry palette above with defaultPalette: false and the count goes to
seventeen. Every one of core's twelve is still there, alongside your five. I checked this
by reading the variable list out of the stylesheet rather than reasoning about it, because
the name of the setting suggests the opposite, and the Theme Handbook describes it as
hiding the default palette.
What defaultPalette: false actually does is hide core's palette in the
editor. The picker offers your five colours and calls them theme colours. It also changes
the collision rule: with the default palette enabled, a theme preset whose slug matches a
default slug is discarded in favour of core's. With it disabled, your preset wins.
That collision rule is worth seeing once, because it is silent. Set
defaultGradients: true and declare a gradient with the slug
midnight, which is also a core gradient, and the stylesheet contains core's
midnight and not yours. No error, no notice, and the editor shows your name on core's
gradient. The preset flags for gradients and duotone work the same way:
{
"settings": {
"color": {
"defaultGradients": false,
"defaultDuotone": false,
"gradients": [
{ "slug": "midnight", "gradient": "linear-gradient(180deg, #0f3d33 0%, #d8e8e2 100%)", "name": "Midnight" }
],
"duotone": [
{ "slug": "ink-sand", "colors": [ "#14161a", "#e8e2d8" ], "name": "Ink and Sand" }
]
}
}
}
Duotone differs from the other presets in a way that matters if you are looking for it
in the wrong place. It produces no CSS custom properties at all. WordPress applies it
through an SVG filter, so if you search the stylesheet for
--wp--preset--duotone you will find nothing and conclude, wrongly, that the
preset was rejected. Check settings.color.duotone in the merged data
instead.
The last gate is settings.color.custom, which removes the custom colour
picker. It does the same job as the equivalent flags on gradients and duotone
(customGradient and customDuotone), and switching off colour
does not switch off the other two. Their defaults differ in one respect worth knowing:
core's own theme.json sets all three custom gates to true, so the free-form
pickers are on unless you turn them off. Disabling them is a statement about who
maintains the site, not about what the CSS can express. Someone who needs an off-palette
colour will enter it as a hex value in a block, and it will become an inline style that no
palette change will ever move.
The contrast ratio is a formula you can run on the back of a receipt
WCAG defines contrast as a ratio between the relative luminance of two colours, from 1 to 21. The thresholds that matter for a theme are 4.5:1 for body text, 3:1 for large text (18.66px bold or 24px regular), and 3:1 for user interface components such as button borders and focus rings. Level AAA raises body text to 7:1, which is worth knowing about because it is the level that people quote when they want a palette to pass everything.
Relative luminance is a weighted sum of the three channels after an inverse gamma correction. Each channel is divided by 255, and then:
c = channel / 255
c' = c / 12.92 if c <= 0.04045
c' = ((c + 0.055) / 1.055) ** 2.4 otherwise
L = 0.2126 * R' + 0.7152 * G' + 0.0722 * B'
ratio = (L_lighter + 0.05) / (L_darker + 0.05)
The 0.05 in the ratio is not arbitrary. It accounts for ambient light reflecting off the screen, and it is what keeps the ratio finite when one colour is pure black. The green channel gets the largest weight because human vision is most sensitive to it, which is why a mid green is darker in luminance than you would guess from its hex value.
Run it on the accent from the file above, #1f6f5c. Its channels are 31,
111 and 92. After the gamma correction they are 0.0137, 0.1590 and 0.1070, and the
weighted sum is 0.2126 * 0.0137 + 0.7152 * 0.1590 + 0.0722 * 0.1070 = 0.1243.
The same calculation for #14161a gives 0.0080, and for
#ffffff it gives 1.0 because the correction leaves pure white and pure black
alone. So the ratio of white on that green is
(1.0 + 0.05) / (0.1243 + 0.05) = 6.02.
Four palette entries, six pairings, two failures
Here is every pairing in a four-colour palette, computed with the formula above. The interesting column is not the ratio; it is which combinations are plausible in a design at all.
| Text on background | Ratio | AA body (4.5) | AA large (3.0) | AAA (7.0) |
|---|---|---|---|---|
| base on contrast | 18.11 | pass | pass | pass |
| base on accent | 6.02 | pass | pass | fail |
| base on accent-soft | 1.27 | fail | fail | fail |
| contrast on accent | 3.01 | fail | pass | fail |
| contrast on accent-soft | 14.28 | pass | pass | pass |
| accent on accent-soft | 4.75 | pass | pass | fail |
The base on accent-soft failure is the least alarming of the two,
because nobody puts white text on a pale tint on purpose. It is a real failure in one
specific place: a heading or a link inside a soft accent band that inherits the body
colour wrong, or text on a soft accent background in a block whose colour was set on a
parent and not overridden on the child. The pairing is easy to create by accident and easy
to miss.
contrast on accent is the failure that ships. The near-black on the green
is 3.01, which passes at large text and misses by a wide margin at body size. Any button
using the accent as a background with the default text colour is at 3.01 unless the theme
explicitly sets the text colour on it. Most themes do not, because the default text colour
is contrast and the accent is a darkish colour, and nobody enters the formula by hand.
The fix people reach for first is to lighten the accent. It does not work. As the green
gets lighter, the ratio against white falls and the ratio against the dark ink rises, and
there is a band in the middle where both are failing. At #1d8a70 the pair is
4.27 with white and 4.24 with the ink, so the accent is now unusable as a background with
either text colour. A slightly lighter or darker green does not have this problem only
because it has not crossed into that band yet. Choosing a button background is not a
lightness tweak; it is picking a colour whose luminance is on one side of the divide.
This is why palettes are designed in pairs rather than as a list. If a colour is going to be a background, it needs a companion that is guaranteed to sit on it, either a very light tint of the same hue or the darkest ink in the palette.
A contrast token per colour is the accessibility feature
The palette above has accent and accent-deep, and the second
one exists only to be a background. #0f3d33 carries white text at 7.59, which
clears AAA. The rule that makes this maintainable is that every colour you intend to use
as a surface ships with the colour that goes on it, declared as a token rather than
decided per block.
You can express that in theme.json without inventing a mechanism. The
style engine resolves preset references, so a button style can point at a background and a
text colour that were checked together:
{
"styles": {
"blocks": {
"core/button": {
"color": {
"background": "var:preset|color|accent-deep",
"text": "var:preset|color|base"
}
}
}
}
}
That generates
background-color: var(--wp--preset--color--accent-deep) and
color: var(--wp--preset--color--base) on the button selector, so the pairing
travels with the theme instead of living in someone's memory. Change
accent-deep in the palette and every button follows it.
The naming matters more than it looks. A colour called accent invites
anyone to use it anywhere, including as a background under dark text, which is the failure
above. A colour called accent-deep or a pair named
surface/on-surface carries the constraint in the name. The
second convention is the one that survives contact with a team, because it makes the
question "what colour goes on this" answer itself.
Two things this lesson has not solved. It has not covered focus indicators, which have their own 3:1 requirement against adjacent colours and which the palette does not control at all. And it has not covered images, where text over a photograph has no single contrast ratio and no palette entry can guarantee anything. The first is a stylesheet problem with a documented answer. The second is a reason not to put text over photography.
Give your theme a five-entry palette with at least two colours intended as
surfaces. Set settings.color.custom to false and decide, and
write down, whether that is right for the site you are building. Set
defaultPalette to false and note what that does and does not
change in the stylesheet.
Then compute the full contrast table for your own palette without a contrast checker, using the formula from this lesson. Every colour against every other colour, both directions, with the pass or fail for body text and large text. Show the luminance of each colour to four decimal places.
Answer three questions from the table. Which pairings fail at body size? Which of
those failures is reachable in your design by a plausible edit, and which is
theoretical? For each reachable failure, say whether the answer is to change the
palette or to declare the pairing explicitly in styles.blocks.
Finally, check the whole table against a published contrast checker. If any number disagrees, find out which of the two is wrong before you move on.
Show the solution and the reasoning
For the palette above the luminances are #ffffff 1.0000,
#14161a 0.0080, #1f6f5c 0.1243 and #d8e8e2
0.7780. A contrast checker will agree to two decimal places. If yours disagrees by more
than rounding, the usual cause is applying the gamma correction to the wrong end: the
correction is applied to the sRGB channel, and the result is what gets weighted. The
other common error is dividing by 255 after the correction instead of before.
One failure is reachable and one is mostly theoretical. The reachable one is
contrast on accent at 3.01, and that is what happens when the accent is a
button background and nobody sets the text colour. Refusing custom colours in the editor
makes this worse rather than better, because it removes the easy fix of letting an editor
pick white text. The right answer is to declare the pairing in
styles.blocks.core/button.color, as above, and to give the accent a
companion that carries white at 4.5 or better. I prefer a separate
accent-deep over darkening accent itself, because the accent is
probably also used for links on white, and there a lighter value reads better.
The theoretical one is base on accent-soft at 1.27. It is theoretical
only in the sense that no one chooses it. It becomes real through inheritance: a group
with a soft accent background, a paragraph inside it that inherits the site text colour,
and a style variation that sets the site text colour to base. The fix is not a palette
change. It is to make the soft accent a token that is only ever used as a background
under contrast, and to set that explicitly where the band is built.
The answer to what defaultPalette: false changes is "the editor and the
collision rule, not the emitted variables". Your five colours are added to core's twelve,
and if you were expecting a smaller stylesheet you will not get one from this key. The
variables cost almost nothing, so the practical cost is not bytes; it is that a
contributor can still reach for --wp--preset--color--pale-pink and get a
colour nobody designed with.
If you took the position that settings.color.custom should stay
true, that is defensible for a site with a real editorial team and hard to
defend for one you are handing over. The palette is the design system. A theme that
permits arbitrary hex values in blocks has a design system for as long as everyone
chooses to use it, and inline styles that outlive the decision.
Progress
Mark this lesson done and it stays marked in this browser.