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

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 backgroundRatioAA body (4.5)AA large (3.0)AAA (7.0)
base on contrast18.11passpasspass
base on accent6.02passpassfail
base on accent-soft1.27failfailfail
contrast on accent3.01failpassfail
contrast on accent-soft14.28passpasspass
accent on accent-soft4.75passpassfail

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.

Lab 35 minutes

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.