Building scalable design systems in WordPress with Gutenberg 2026

Building scalable design systems in WordPress with Gutenberg 2026

Last verified: October 12, 2026
22 min read
Guide
Full-stack developer
UI/UX designer

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.

#How to set up theme.json version 3 for a design system

The living specification is theme.json version 3. WordPress 6.6 introduced it: the WordPress Developer Blog post on section styles states that the version 3 schema is supported only on WordPress 6.6 and later, or with the Gutenberg plugin on an older core. 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. It closes the free pickers for color, font size and spacing, and gives the editor fixed scales instead:

{
  "$schema": "https://schemas.wp.org/wp/6.6/theme.json",
  "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" },
        { "slug": "accent", "name": "Accent", "color": "#c2410c" }
      ]
    },
    "typography": {
      "customFontSize": false,
      "dropCap": false,
      "fontSizes": [
        { "slug": "small", "name": "Small", "size": "0.875rem" },
        { "slug": "medium", "name": "Medium", "size": "1rem" },
        { "slug": "large", "name": "Large", "size": "1.5rem" },
        { "slug": "x-large", "name": "Extra large", "size": "2.25rem" }
      ]
    },
    "spacing": {
      "customSpacingSize": false,
      "defaultSpacingSizes": false,
      "spacingSizes": [
        { "slug": "20", "name": "XS", "size": "0.5rem" },
        { "slug": "30", "name": "S", "size": "1rem" },
        { "slug": "40", "name": "M", "size": "1.5rem" },
        { "slug": "50", "name": "L", "size": "2.5rem" },
        { "slug": "60", "name": "XL", "size": "4rem" }
      ]
    }
  }
}

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.

customSpacingSize: false removes the free-form spacing input, and defaultSpacingSizes: false stops core from adding its own spacing scale next to yours. Both keys are listed under settings.spacing in the living reference, next to spacingSizes and spacingScale.

appearanceTools: true is not a lockdown switch. The living reference lists what it turns on: background image and size, border color, radius, style and width, link, heading, button and caption colors, aspect ratio, height, min-height, min-width and width, sticky position, block gap, margin and padding, and line height. 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.

#How Gutenberg turns theme.json presets into CSS variables

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.

Values that have no preset type, such as shadow depths or corner radii you want to name, go under settings.custom. The handbook documents that each key there becomes a --wp--custom--{key} property, with nested keys joined by a double hyphen. A small token sheet for the example above ends up looking like this in the generated CSS:

/* Presets from settings.color.palette and settings.typography.fontSizes */
--wp--preset--color--primary: #0b3d91;
--wp--preset--color--accent: #c2410c;
--wp--preset--font-size--large: 1.5rem;

/* Presets from settings.spacing.spacingSizes */
--wp--preset--spacing--30: 1rem;
--wp--preset--spacing--40: 1.5rem;

/* From settings.custom: { "radius": { "card": "8px" } } */
--wp--custom--radius--card: 8px;

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.

#How to split theme.json on a large site

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.

When a team does choose the compile route, the layout is usually this:

theme/
├── theme.json            (generated, committed)
├── config/
│   ├── color.json
│   ├── typography.json
│   ├── spacing.json
│   └── custom.json
└── build-theme-json.js   (merges config/ into theme.json)

Design, development and marketing can then edit their own files without merge conflicts on one large JSON document. The cost is that the merge script becomes code someone has to maintain, test and upgrade when the schema changes.

#How style variations swap a palette without new CSS

An alternative palette does not need a second stylesheet. The core route is a style variation: a JSON file in /styles that redefines the same slugs. The site owner picks it in the Site Editor under Styles, and every block that already consumes the preset variables follows.

Style variation in /styles/dark.json:

{
  "$schema": "https://schemas.wp.org/wp/6.6/theme.json",
  "version": 3,
  "title": "Dark",
  "settings": {
    "color": {
      "palette": [
        { "slug": "base", "name": "Base", "color": "#111111" },
        { "slug": "contrast", "name": "Contrast", "color": "#f5f5f5" }
      ]
    }
  },
  "styles": {
    "color": {
      "background": "var(--wp--preset--color--base)",
      "text": "var(--wp--preset--color--contrast)"
    }
  }
}

The variation keeps the slug names and changes only their values. That is the rule that makes it work: blocks store the slug, not the hex. An automatic light and dark switch based on prefers-color-scheme does not ship with core. That is still CSS you write.

Since WordPress 6.6 the same folder also accepts color-only and typography-only variations, according to the Developer Blog post on block style variations. For a group of brands on one codebase, that gives a clean split: shared spacing and layout in the main theme.json, one variation file per brand palette.

#How section styles work in theme.json since WordPress 6.6

Before 6.6, a custom block style such as “Boxed” on a Pullquote meant a CSS class plus hand-written CSS. WordPress 6.3 had only allowed theme.json to edit the block styles registered by core. In the June 2024 Developer Blog post, Justin Tadlock explains that WordPress 6.6 lets you style any registered block style variation in theme.json, under styles.blocks.{block}.variations.{name}, including the elements and blocks nested inside it. The same post describes “section styles” as a concept introduced in 6.6: a block style variation applied to a section, together with the nested elements and blocks it restyles.

A section style can be registered from a JSON file in /styles (WordPress also looks in sub-folders, so /styles/section/ works). The blockTypes key lists the blocks it applies to:

{
  "$schema": "https://schemas.wp.org/wp/6.6/theme.json",
  "version": 3,
  "slug": "section-dark",
  "title": "Dark section",
  "blockTypes": [ "core/group", "core/columns" ],
  "styles": {
    "color": {
      "background": "var:preset|color|contrast",
      "text": "var:preset|color|base"
    },
    "elements": {
      "link": {
        "color": { "text": "var:preset|color|accent" }
      }
    }
  }
}

The same variation can be registered in PHP. In 6.6, register_block_style() accepts an array of block names as its first argument and a style_data key that takes the same structure as the JSON file:

register_block_style(
	array( 'core/group', 'core/columns' ),
	array(
		'name'       => 'section-dark',
		'label'      => __( 'Dark section', 'themeslug' ),
		'style_data' => array(
			'color' => array(
				'background' => 'var:preset|color|contrast',
				'text'       => 'var:preset|color|base',
			),
		),
	)
);

For a design system this replaces the most common source of drift: editors picking background and text colors on each Group by hand. With section styles they pick “Dark section” from the Styles panel, and the pairing of background, text and link colors comes from one definition. A global style variation can then retune that section through the top-level styles.variations property, so a brand variation changes the dark section along with everything else. The same post notes a limit to keep in mind: section styles work best on container blocks such as Group, Columns and Cover.

#Theme patterns vs synced patterns in WordPress

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. The same handbook adds that synced patterns can only be created and managed from the WordPress admin.

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. Those objects are not in Git. Staging and production each need the same wp_block row, or you recreate them by hand.

Use that split on purpose:

KindLives inUpdates existing pagesShip across environments
Theme pattern (/patterns)the theme, in Gitnoyes, with the deploy
Synced patternthe databaseyesonly if you copy wp_block data
Template part (header/footer)theme file until edited, then DByes, as the partfile 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 two times.

In practice a pattern library has two layers. Small section patterns are the building blocks: a hero with heading, intro, button and image, a service card with icon, title, text and link, a testimonial with photo, quote, name and role, a call-to-action banner. Full-page patterns combine them into the page types marketing repeats: a service landing page, a case study, a team page, a contact page. Editors pick a full-page pattern and replace the sample copy, so a new page starts inside the brand rules instead of on an empty canvas.

Synced patterns earn their place for content that must change everywhere on the same day: legal notices, consent lines, company address and phone blocks, the call to action of the current campaign.

#How pattern overrides and the Block Bindings API connect content

A synced pattern used to be all or nothing: either every instance was identical, or you detached it and lost the sync. WordPress 6.6 added overrides in synced patterns. In the Developer Blog walkthrough by Nick Diego, a card keeps its shared design while each instance changes its heading, text and button. In the editor you open the synced pattern with Edit original, select a block, click “Enable overrides” in the Advanced panel of the block inspector, and give it a unique name. The block markup then carries a binding to the core/pattern-overrides source.

Overrides rely on two older mechanisms. Content-only editing, introduced in WordPress 6.1, is templateLock: "contentOnly" on a container block: only nested text and media stay editable, and inspector controls for settings and styles are disabled. The second is the Block Bindings API, available since WordPress 6.5, which connects a block attribute to a data source instead of to static content.

The handbook lists which core blocks and attributes accept bindings by default:

BlockBindable attributes
Imageid, url, title, alt, caption
Heading, Paragraphcontent
Buttonurl, text, linkTarget, rel
Navigation Link, Navigation Submenuurl
Post Datedatetime

The built-in sources are core/post-meta (the meta key must be registered with show_in_rest and must not start with an underscore), core/pattern-overrides, and, since WordPress 6.9, core/post-data (date, modified and link) and core/term-data. A plugin can add its own source with register_block_bindings_source() on init, passing a label, an optional uses_context array and a get_value_callback. The binding itself sits in the block’s metadata:

<!-- wp:paragraph {"metadata":{"bindings":{"content":{"source":"core/post-meta","args":{"key":"product_sku"}}}}} -->
<p></p>
<!-- /wp:paragraph -->

For a design system this is how a pattern stays a pattern while its data comes from structured fields. A product card pattern can bind its SKU paragraph to post meta, so editors never type it into the layout. Two limits from the handbook matter here. Editor-side registration with registerBlockBindingsSource() arrived in 6.7, and a bound value is not editable in the editor unless the source provides canUserEditValue. The overrides post also noted, as of June 2024, that synced patterns are stored only in the database and cannot be packaged as pattern files in a theme, with early explorations tracked in a Gutenberg issue.

#How to version theme patterns with Git

WordPress does not version patterns: there are no states, no approvals, and no automatic migration of inserted instances. What exists is Git on the /patterns folder, and the team sets the cycle:

  1. Draft: the new pattern lives on a branch.
  2. Review: brand or legal approve it in the pull request.
  3. Published: it is deployed and appears in the inserter.
  4. Retired: it is removed from the inserter by deleting the file or the register_block_pattern() call. Pages that already inserted it keep their markup, and you have to touch them one by one.

Step four is the reason to keep the pattern set short. Every pattern you publish leaves copies in the content that the theme can no longer reach.

#How to lock Gutenberg templates with templateLock

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.

The block templates reference defines the values. all prevents inserting, moving and deleting blocks. insert prevents inserting or removing blocks but allows moving existing ones. contentOnly prevents all operations, hides blocks without content from the List View, and cannot be overridden by child blocks. The Group block declares templateLock as an attribute in its block.json, so the lock can live directly in the markup of a template or pattern.

Two more levels exist. A single block can carry a lock attribute with move and remove flags, and the reference states that the block-level lock takes priority over templateLock. A custom post type can register its own template array and a template_lock in register_post_type(), so every new product starts from the same block structure.

Inside a custom block the same lock applies to its inner blocks:

<InnerBlocks
	template={ TEMPLATE }
	templateLock="all"
	allowedBlocks={ [ 'core/heading', 'core/paragraph', 'core/button' ] }
/>

Editors can still change text and images, but they cannot reorder, remove or add blocks outside the defined structure.

#When to build a custom Gutenberg block with block.json

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.

A block folder in the design system repository looks like this:

blocks/
└── product-comparison/
    ├── block.json
    ├── edit.js       (editor UI)
    ├── save.js       (front-end markup)
    ├── style.scss    (front-end and editor styles)
    └── editor.scss   (editor-only styles)

The block metadata reference uses "apiVersion": 3 in its example. Know where block.json validation stops. The attributes reference says each attribute definition contains at least a type or an enum, plus where the value is read from. enum restricts the stored value. There is no maxLength, minimum or maximum that Gutenberg enforces for you:

{
  "apiVersion": 3,
  "name": "wppoland/product-comparison",
  "attributes": {
    "heading": { "type": "string", "default": "" },
    "price": { "type": "number", "default": 0 },
    "variant": {
      "type": "string",
      "enum": [ "primary", "secondary", "accent" ],
      "default": "primary"
    }
  }
}

Range and length limits are applied in the edit component or in REST validation on save:

<RichText
	tagName="h3"
	value={ attributes.heading }
	onChange={ ( heading ) => setAttributes( { heading: heading.slice( 0, 120 ) } ) }
/>

The @wordpress/scripts reference covers the rest of the toolchain from one dependency. build produces a production bundle and start rebuilds on every change. lint-js and lint-style apply the WordPress coding standards to scripts and stylesheets. test-e2e runs end-to-end tests with Playwright, and plugin-zip packages the plugin. The same package detects block.json files in the source directory and uses them as entry points, so a new block folder needs no webpack change.

#How to use Storybook for Gutenberg UI components

The Gutenberg project itself documents its UI components in Storybook. The storybook folder README in the Gutenberg repository states that the project “uses Storybook to view and work with the UI components developed in the WordPress packages”, publishes the result at wordpress.github.io/gutenberg, and runs it locally with npm run storybook:dev from the repository root.

Two practices from that README transfer well to a client design system. Each component page declares a status (recommended, use-with-caution, not-recommended or unaudited) that shows in the sidebar, so developers see which components are safe to build on. And the repository commits a manifest snapshot of every component and its props: a pull request that removes a prop description fails until someone acknowledges the change.

What Storybook does not cover is the content layer. Patterns, theme.json presets and section styles render inside the block editor, so the reliable preview for them is a page on a staging site that inserts every pattern, edited with an editor account. Storybook fits the React parts of the system: the edit components of custom blocks and any shared sidebar controls built from @wordpress/components, which is the library the public Storybook documents.

#How to move Figma tokens into theme.json

There is no official sync between Figma and WordPress. Core does not read Figma files, and no Figma plugin writes to a production site by itself. What you can build is a build step: tokens leave the design tool as JSON, and a script writes them into theme.json, which is what the deploy ships.

The names almost never match at first, so that mapping is code someone maintains. A workflow that holds up:

  1. The designer changes a color in Figma.
  2. A token plugin exports the updated tokens as JSON.
  3. CI runs the build script, which rewrites settings.color.palette in theme.json.
  4. Visual regression tests compare screenshots before and after.
  5. A person reads the theme.json diff and approves the pull request.

Stop the automation at step five. A webhook that deploys production without anyone reading the diff is a demo, not a system. A brand color is exactly the kind of change that deserves a human review.

ToolJobWhat you still write
Tokens Studio for FigmaExports tokens to JSONThe mapping from that JSON to theme.json settings
Style DictionaryTransforms tokens between platformsA custom output format for theme.json
GitHub ActionsRuns the build and opens the pull requestThe review step before merge
PlaywrightVisual comparisonThe routes and states that get captured

Documentation can come almost free from material that already exists: a page on the site that inserts every pattern in the library, the Git history of /patterns and theme.json as the changelog, and the visual test screenshots as the before and after record.

#How to check accessibility in a WordPress design system

None of this is done by WordPress for you. These are checks you build around the theme.

The palette is a short list of hex values in a JSON file, so contrast can be checked in the build. A test walks the allowed text and background pairs, computes the WCAG ratio, and fails the build when a pair drops below 4.5:1 for normal text. It names the failing combination by slug, not by hex, so the fix lands in theme.json. The block editor already warns in the inspector when a chosen color combination has low contrast, but that warning does not stop publishing. Section styles help here: when the background and text pair is defined once in a section style, it is tested once.

In CI, run axe-core against the main templates and every published style variation, and add keyboard navigation tests for the patterns that contain interactive elements. Remember the limit: automated tools catch only part of the WCAG criteria. The rest is still a manual review with a keyboard and a screen reader.

#How block assets load on demand in a block theme

A badly built design system can be slower than a hand-coded site, because every pattern drags its CSS along. Core helps if you let it. In _add_default_theme_supports(), which runs for block themes, WordPress adds both should_load_separate_core_block_assets and should_load_block_assets_on_demand as __return_true filters (see wp-includes/theme.php in wordpress-develop). The docblock in script-loader.php describes the effect: block assets load only when the block is rendered. A classic theme opts in with:

add_filter( 'should_load_separate_core_block_assets', '__return_true' );

A page without a gallery then does not load the gallery CSS. For custom blocks, the practical rule follows from this: declare style, editorStyle, viewScript or viewScriptModule in block.json instead of enqueueing files globally from functions.php, so WordPress can load them only where the block appears. What WordPress does not do is generate critical CSS or strip unused rules. That is still the job of your build or a cache layer.

Per-component budgets are a team decision, not a core measurement, and they work only if someone checks them in every pull request. The cheapest win is often to need no script at all: an FAQ accordion built on the Details block outputs <details> and <summary>, which open and close without JavaScript and work with the keyboard.

#Scalable WordPress design system checklist

On WordPress development work the sequence is the same every time:

  1. Tokens in theme.json version 3, picker locked, core palette hidden.
  2. Section styles for the background and text pairs, so editors stop coloring Groups by hand.
  3. A short pattern set in /patterns for the layouts marketing actually repeats.
  4. One or two synced patterns for copy that legal owns, exported with the content sync, never left as “we’ll recreate it on prod”.
  5. Custom blocks only after a pattern on core blocks has failed a real attribute constraint.
  6. 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.

Planning a design system on WordPress? Write to us.

Next step

Turn the article into an actual implementation

This block strengthens internal linking and gives readers the most relevant next move instead of leaving them at a dead end.

Want this implemented on your site?

If you want to convert the article into a working site improvement, redesign, or build plan, I can define the scope and implement it.

Related cluster

Explore other WordPress services and knowledge base

Strengthen your business with professional technical support in key areas of the WordPress ecosystem.

Article FAQ

Frequently asked questions

Practical answers to apply the topic in real execution.

SEO-readyGEO-readyAEO-ready4 Q&A
Why not keep the design system in Figma and skip theme.json?#
Figma is the prototype. theme.json is what Gutenberg and the front end actually read. There is no official Figma-to-WordPress sync in core. If a token must survive a deploy, it belongs in theme.json (or a build that writes theme.json), not in a Figma comment.
Is theme.json hard to maintain on a large site?#
One 2 000-line file is hard. Core already splits some of that: style variations under /styles, child-theme merge, and the wp_theme_json_data_theme filter. A custom compile of JSON partials is a team choice, not a WordPress feature.
Do custom blocks still belong in a design system?#
Yes, when a layout needs attributes you can validate. A product comparison grid with a required SKU field is a block. A hero with three heading levels and a brand color is a pattern sitting on core blocks.
If I edit a pattern in the theme, will every page update?#
No. Theme-registered patterns are unsynced copies. Changing the file changes the next insert, not the blocks already on posts. For a legal footer that must update everywhere, create a synced pattern in the editor (it lives in the database) and accept that it is not in Git.

Need an FAQ tailored to your industry and market? We can build one aligned with your business goals.

Let’s discuss

Related Articles