A WordPress design system is the set of tokens, allowed editor controls, and insertable layouts that Gutenberg will actually enforce. Figma files do not enforce anything on the published site. In 2026 that contract is theme.json version 3, the /patterns directory, and a small number of custom blocks. Everything else is a prototype or a database object you have to treat as such.
We keep seeing the same failure on large editorial sites: the brand book lives in Figma, the CSS lives in five stylesheets, and the editor still offers a custom color picker. Marketing then ships a campaign hex that exists on one landing page. The next campaign copies it. Six months later nobody can say which green is the brand green. Gutenberg can prevent that, but only if you turn the controls off and put the palette in theme.json.
This is not a page-builder migration sermon. If you are choosing Gutenberg against Elementor or Divi, read the 2026 builder comparison. If you are choosing a block theme against a classic PHP theme, read classic vs block themes. This piece is the layer after that choice: how the system stays consistent once editors are in the inserter every day.
theme.json version 3 is the contract
The living specification is theme.json version 3. WordPress 6.6 introduced it. Older version: 2 files still load. New settings land on version 3. There is no version 4 in that spec, despite what a lot of 2025 roundups claimed.
Point the file at the schema that matches the minimum WordPress you support, not at trunk, unless the site always runs the Gutenberg plugin:
{
"$schema": "https://schemas.wp.org/wp/6.6/theme.json",
"version": 3,
"settings": {},
"styles": {}
}settings is what the editor is allowed to do. styles is the default look when nobody overrides a block. Mixing those two is how teams accidentally ship a pretty default that editors can still break with one click.
For a site that must stay on-brand, the first settings block we write looks like this:
{
"version": 3,
"settings": {
"color": {
"custom": false,
"customDuotone": false,
"customGradient": false,
"defaultPalette": false,
"defaultGradients": false,
"defaultDuotone": false,
"palette": [
{ "slug": "base", "name": "Base", "color": "#ffffff" },
{ "slug": "contrast", "name": "Contrast", "color": "#111111" },
{ "slug": "primary", "name": "Primary", "color": "#0b3d91" }
]
},
"typography": {
"customFontSize": false,
"dropCap": false
}
}
}color.custom: false is the old add_theme_support( 'disable-custom-colors' ). defaultPalette: false hides the core gray-and-blue swatches so the picker only shows your slugs. The mapping is in the Global Settings and Styles handbook. If those two flags stay at the defaults (true), you do not have a design system. You have a suggestion.
appearanceTools: true is not a lockdown switch. It turns on border, margin, padding, sticky, min-height and line-height controls. Use it when layout work belongs in the editor. Leave it off, or set the individual keys, when you want spacing to come only from presets.
Presets become CSS variables you can actually call
Each palette slug becomes a custom property --wp--preset--color--{slug} and utility classes such as .has-{slug}-color and .has-{slug}-background-color. Font sizes and spacing sizes follow --wp--preset--font-size--{slug} and --wp--preset--spacing--{slug}. That naming is in the same handbook. Custom CSS in the theme should use those variables, not a second set of --brand-primary tokens that drift.
That is the whole “design tokens in Gutenberg” story. WordPress does not import Tokens Studio. It emits presets from theme.json. If the design team already has tokens in JSON, the honest pipeline is a build step that writes settings.color.palette (and the type and spacing scales) into theme.json. The deploy then ships one file. A webhook from Figma that “pushes production” without that write is a demo, not a system.
Child themes merge theme.json on top of the parent. Style variations live as extra JSON files under /styles. PHP can still amend the tree with the wp_theme_json_data_theme filter when a network site needs a different primary without forking the theme. Those three splits are what core gives you. Compiling twelve JSON partials in a Node task is fine if your team already owns that task. It is not required, and it is not documented as a WordPress feature.
Theme patterns are not synced patterns
This is the distinction that burns projects.
Theme Handbook: Introduction to Patterns is blunt: patterns you put in /patterns are not synced. They are templates for the next insert. WordPress copies the block markup into the post. Edit the PHP file later and existing pages do not change. There is an open Gutenberg ticket to ship synced patterns from theme files. It is not how core works today.
Synced patterns (reusable blocks before WordPress 6.3) are wp_block posts in the database. You create them in the editor. Every instance stays in lockstep. WordPress 6.6 added overrides on synced patterns, so a card can keep shared design while the heading and image vary per instance. Those objects are still not in Git. Staging and production each need the same wp_block row, or you recreate them by hand.
Use that split on purpose:
| Kind | Lives in | Updates existing pages | Ship across environments |
|---|---|---|---|
Theme pattern (/patterns) | the theme, in Git | no | yes, with the deploy |
| Synced pattern | the database | yes | only if you copy wp_block data |
| Template part (header/footer) | theme file until edited, then DB | yes, as the part | file until someone customizes it |
Heroes, case-study layouts, and landing-page skeletons belong in /patterns. A legal disclaimer, a cookie-line, or a banner that legal can rewrite this afternoon belongs in a synced pattern, with a documented export so staging is not a surprise.
Register theme patterns by dropping a PHP file with a file header into /patterns, which is the path the registering patterns chapter recommends, or by calling register_block_pattern() on init. One pattern, one method. Do not register the same slug twice.
Lock templates the same way you lock colors
A palette with custom: false still fails if the page template is an empty Group and the inserter offers every core block. For page types that must stay on-model (product, location, documentation), lock the template: allowedBlocks on the group, templateLock: "insert" or "all" where the layout must not be rearranged, and a pattern as the default content so the editor never starts from a blank canvas.
That is also where custom blocks earn their keep. Core Heading plus Paragraph plus Image covers most marketing modules. A comparison table that must carry a SKU, a currency, and a “last verified” date needs attributes and block.json validation. We build those with @wordpress/scripts, keep the save markup boring, and refuse to recreate a page builder inside the inspector. If the block is a skin around inner blocks, prefer a pattern.
What we actually run on client sites
On WordPress development work the sequence is the same every time:
- Tokens in
theme.jsonversion 3, picker locked, core palette hidden. - A short pattern set in
/patternsfor the layouts marketing actually repeats. - One or two synced patterns for copy that legal owns, exported with the content sync, never left as “we’ll recreate it on prod”.
- Custom blocks only after a pattern on core blocks has failed a real attribute constraint.
- A review in the Site Editor with an editor account, not an administrator account, because admins still see tools you thought you hid.
If the front end already uses utility CSS, keep theme.json as the token source and map slugs into that CSS. We wrote that split down for Tailwind in Harnessing Tailwind CSS for WordPress development. Gutenberg does not care which CSS framework consumes --wp--preset--color--primary. It cares that the editor and the front name the same slug.
The system is working when a new landing page is a pattern insert, the color picker has eight swatches, and a request for “just this one orange” is a theme.json pull request, not a hex in a Group block.






