Home › Blog › Article

Building a Design System with theme.json Tokens

Building a Design System with theme.json Tokens

Define your design system in /wp-content/themes/your-theme/theme.json: put reusable choices under settings, apply them under styles, use stable slugs, and let WordPress 6.9 / 7.0 compile CSS custom properties and block presets. You have done it right when the Site Editor shows only your palettes, font sizes and spacing steps, generated CSS contains --wp--preset--* variables, and a fresh page renders identically in the editor and front end without custom CSS overrides. Verify with wp theme list, browser DevTools, and wp eval against WP_Theme_JSON_Resolver.

How It Works: From JSON to Editor UI, CSS Variables and Block Markup

WordPress reads theme.json during theme bootstrap, merges it with core defaults, user Site Editor overrides, and child-theme data through WP_Theme_JSON_Resolver::get_merged_data(). As of WordPress 6.9 in 2026, the resolver normalizes the schema version, expands presets into --wp--preset--color--{slug}, --wp--preset--font-size--{slug} and --wp--preset--spacing--{slug} custom properties, then emits a <style id='global-styles-inline-css'> tag via wp_enqueue_global_styles(). The same data is serialized to the editor as __experimentalGlobalStylesBaseStyles / editor settings, so controls in the inspector are generated from the same tree that produces front-end CSS.

When an editor picks “Brand / Ink”, the block saves {"textColor":"ink"} in the comment delimiter, not a hex value. WP_Block_Supports and wp_get_global_stylesheet() translate that slug into color: var(--wp--preset--color--ink). This indirection is the point of theme.json design tokens: changing one token in JSON repaints every stored post without a database migration. Core also builds .has-ink-color, .has-ink-background-color, .has-body-font-size and .has-40-gap utility classes from the same preset list.

Inspect the mechanism directly:

# Confirm active theme and schema support (WP-CLI 2.12+, WordPress 6.9+)
wp theme list --status=active
wp eval 'echo json_encode( WP_Theme_JSON_Resolver::get_merged_data()->get_settings(), JSON_PRETTY_PRINT );' | head -80
wp eval 'echo substr( wp_get_global_stylesheet(), 0, 1200 );'

# In DevTools Console, on any edited page:
# getComputedStyle(document.documentElement).getPropertyValue('--wp--preset--color--ink')
# document.getElementById('global-styles-inline-css').textContent.includes('--wp--preset--spacing--40')

Rollback is deliberately boring: keep theme.json in Git, deploy the previous commit, then run wp cache flush and wp transient delete --all. Global styles are cached in the theme_json object cache group; stale transients are the most common reason a reverted file still looks broken. If user overrides were saved in wp_posts as a wp_global_styles CPT, reset them with wp post delete $(wp post list --post_type=wp_global_styles --format=ids) --force only after exporting that post first.

Step-by-Step Process: Minimal Runnable Theme in 2026

  1. Create /wp-content/themes/token-lab/style.css with a header, index.php, templates/index.html and theme.json; require WordPress 6.9 or newer.
  2. Paste the minimal token file below, including "version": 3 and "appearanceTools": true.
  3. Activate with wp theme activate token-lab, then flush the object cache and reload the Site Editor.
  4. Create a page with a Paragraph and Group block; assign Ink text, Canvas background, Body font and spacing 40.
  5. Verify generated variables in DevTools and confirm the saved block comment contains slugs, not raw values.
  6. Change only --ink‘s hex, redeploy, and confirm the existing page updates without editing the post.

Minimal copy-pasteable theme.json (schema v3, WordPress 6.9 / 7.0 compatible):

{
  "$schema": "https://schemas.wp.org/trunk/theme.json",
  "version": 3,
  "settings": {
    "appearanceTools": true,
    "color": {
      "custom": false,
      "customDuotone": false,
      "customGradient": false,
      "defaultPalette": false,
      "palette": [
        { "slug": "ink", "name": "Ink", "color": "#14161a" },
        { "slug": "canvas", "name": "Canvas", "color": "#f7f5f0" },
        { "slug": "accent", "name": "Accent", "color": "#c2410c" }
      ]
    },
    "typography": {
      "customFontSize": false,
      "defaultFontSizes": false,
      "fluid": true,
      "fontFamilies": [
        {
          "slug": "system-sans",
          "name": "System Sans",
          "fontFamily": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
        }
      ],
      "fontSizes": [
        { "slug": "body", "name": "Body", "size": "1rem", "fluid": { "min": "0.95rem", "max": "1.0625rem" } },
        { "slug": "display", "name": "Display", "size": "2.5rem", "fluid": { "min": "1.875rem", "max": "3.25rem" } }
      ]
    },
    "spacing": {
      "defaultSpacingSizes": false,
      "customSpacingSize": false,
      "spacingScale": { "operator": "*", "increment": 1.5, "steps": 6, "mediumStep": 1.5, "unit": "rem" },
      "units": [ "rem", "px", "%" ]
    },
    "blocks": {
      "core/pullquote": { "color": { "palette": [ { "slug": "accent", "name": "Accent", "color": "#c2410c" } ] } }
    }
  },
  "styles": {
    "color": { "background": "var(--wp--preset--color--canvas)", "text": "var(--wp--preset--color--ink)" },
    "typography": { "fontFamily": "var(--wp--preset--font-family--system-sans)", "fontSize": "var(--wp--preset--font-size--body)", "lineHeight": "1.6" },
    "spacing": { "padding": { "top": "var(--wp--preset--spacing--40)", "bottom": "var(--wp--preset--spacing--40)" } },
    "elements": { "link": { "color": { "text": "var(--wp--preset--color--accent)" } }, "h2": { "typography": { "fontSize": "var(--wp--preset--font-size--display)" } } },
    "blocks": { "core/group": { "spacing": { "padding": "var(--wp--preset--spacing--30)" } } }
  },
  "templateParts": [ { "name": "header", "area": "header" } ]
}

Companion templates/index.html:

<!-- wp:template-part {"slug":"header","area":"header"} /-->
<!-- wp:group {"layout":{"type":"constrained","contentSize":"720px"},"backgroundColor":"canvas","style":{"spacing":{"padding":{"top":"var:preset|spacing|40","bottom":"var:preset|spacing|40"}}}} -->
<div class="wp-block-group has-canvas-background-color has-background">
  <!-- wp:paragraph {"fontSize":"body","textColor":"ink"} -->
  <p class="has-ink-color has-text-color has-body-font-size">Token-driven content.</p>
  <!-- /wp:paragraph -->
</div>
<!-- /wp:group -->

Validation command: wp eval 'json_decode( file_get_contents( get_stylesheet_directory() . "/theme.json" ), true, 512, JSON_THROW_ON_ERROR ); echo "OKn";'. A JSON parse error silently falls back to core defaults, so wire this into CI alongside npx @wordpress/scripts lint-json. For teams shipping this into client builds, our WordPress Theme Development & Customization workflow pins the schema version and runs the same resolver assertion on staging before release.

Get a Quote

Email + requirement + budget range — we reply with a quote within 24 hours.

settings vs styles: Declaration Layer vs Application Layer

settings answers “what may an editor choose?”; styles answers “what is the default before they choose?”. Internally WP_Theme_JSON::get_settings() builds the preset arrays and UI toggles, while WP_Theme_JSON::get_stylesheet() walks the styles tree and emits selectors such as body, a, h2, .wp-block-group and :root :where(...). A token listed in settings.color.palette but never referenced in styles is available in the inspector yet changes nothing until applied. A value written only in styles.color.text renders correctly but is not reusable in the UI.

How to separate them cleanly

Keep all raw values in settings; keep all styles values as var(--wp--preset--*) references. Set custom: false, customFontSize: false, customSpacingSize: false, defaultPalette: false and defaultFontSizes: false so the color picker, free-form size input and core fallback palette disappear. Per-block overrides belong in settings.blocks."core/xxx" to narrow choices, and in styles.blocks."core/xxx" to change defaults; the resolver merges block-level settings over global settings before generating CSS.

Verify and roll back

Open a Paragraph block: the color panel must show exactly Ink / Canvas / Accent and no “Custom” link. Then run wp eval 'echo wp_get_global_stylesheet( array( "styles" ) );' and confirm body{ ... var(--wp--preset--color--ink) ... }. A frequent pitfall is putting a hex directly in styles: the page looks right on day one, but a Site Editor “Reset to theme defaults” wipes the visual link to the token and later palette edits do nothing. Another pitfall is leaving defaultPalette unset, which reintroduces core’s ~12 swatches in WordPress 6.9. Roll back by restoring the previous settings block only; because posts store slugs, no content repair is needed. If you accidentally enabled custom values and editors saved hex codes, search with wp search-replace '"textColor":"#c2410c"' '"textColor":"accent"' wp_posts --dry-run before committing the replacement.

slug naming: The Contract Stored Inside Every Post

Slugs are the primary key of the token system. Core sanitizes them with sanitize_key()-style rules, then uses them verbatim in CSS variable names, utility class names, theme.json preset lookups and serialized block attributes. ink becomes --wp--preset--color--ink, .has-ink-color and {"textColor":"ink"}. Rename ink to brand-dark in 2026 and every existing post still says ink; the resolver finds no preset, drops the utility class, and the text silently falls back to inherited body color.

Naming rules we enforce on agency builds

  • Use lowercase ASCII, digits and single hyphens only: ink, surface-2, space-40. No underscores, camelCase, leading digits or non-Latin characters.
  • Name by role, not appearance: accent survives a rebrand from orange to blue; orange becomes a lie and tempts a rename.
  • Never reuse a slug for a different semantic token. Add accent-2 and deprecate accent over one release instead.
  • Keep slugs identical across settings, styles, block templates and custom block attributes; document them in /docs/tokens.md inside the theme repo.
  • Reserve body, display, 20–80 for size steps so var:preset|spacing|40 in HTML templates stays readable.

Verify and roll back

Run wp eval '$s=WP_Theme_JSON_Resolver::get_merged_data()->get_settings(); foreach(["color","typography","spacing"] as $k){echo $k.": ".implode(",",array_column($s[$k]["palette"]??$s[$k]["fontSizes"]??$s[$k]["spacingSizes"]??[],"slug"))."n";}' and diff the output against /docs/tokens.md in CI. Grep serialized content for orphan slugs with wp db query "SELECT ID FROM wp_posts WHERE post_content LIKE '%"textColor":"ink"%'" after any rename. If a rename already shipped, either keep an alias preset with the old slug pointing at the new value for one release, or run a targeted wp search-replace on wp_posts.post_content with --dry-run first. Do not “fix” it with a CSS rule like .has-ink-color{color:...}; that bypasses the resolver and breaks editor/front-end parity. Our custom block development guide shows the same slug contract from the block.json side, which matters when a bespoke block reads useSettings('color.palette').

spacingScale and fluid typography: Generated Steps, Not Hardcoded Media Queries

settings.spacing.spacingScale is expanded server-side by WP_Theme_JSON::compute_spacing_sizes() into a spacingSizes array before CSS generation. With operator: "*", increment: 1.5, steps: 6, mediumStep: 1.5 and unit: "rem", WordPress 6.9 produces slugs 20, 30, 40, 50, 60, 70 with 40 anchored at 1.5rem; operator: "+" produces linear steps instead. The generated --wp--preset--spacing--40 variables feed padding, margin, gap and blockGap controls. Set defaultSpacingSizes: false or you will get both core’s scale and yours, with confusing duplicate labels in the inspector.

Fluid typography works differently: with typography.fluid: true, each fontSizes entry carrying a fluid: {min, max} object is compiled by wp_get_typography_font_size_value() into a clamp() expression using the viewport bounds from settings.layout.wideSize (or core’s 1600px fallback). The static size remains the editor preview and the no-JS fallback. A fontSizes entry without a fluid object stays fixed even when global fluid is on — a common reason a “Display” heading still jumps at breakpoints.

# Inspect the computed scale without guessing
wp eval '$s = WP_Theme_JSON_Resolver::get_merged_data()->get_settings(); print_r( $s["spacing"]["spacingSizes"] );'
wp eval 'echo wp_get_global_stylesheet();' | grep -o 'clamp([^)]*)' | head -5

# Expected front-end output for the display token:
# --wp--preset--font-size--display: clamp(1.875rem, 1.875rem + ((100vw - 20rem) * 1.09), 3.25rem);

Pitfalls and rollback

Do not mix spacingScale with a hand-written spacingSizes array; the explicit array wins and the scale is ignored, which looks like “my increment did nothing”. Do not use px for mediumStep with rem units, and do not set steps above 8 — the inspector becomes unusable on small screens. If fluid type causes CLS on a client site, roll back by setting "fluid": false at settings.typography while leaving each min/max object in place; the resolver then emits the static size and you can re-enable per entry later. Always test at 360px, 768px and 1600px in DevTools device mode and compare getComputedStyle(h2).fontSize. For vendor-side acceptance criteria around regressions like this, the verification checklist for post-fix WordPress audits maps well onto token rollouts even though it was written for SEO work. Official schema details live in the Global Settings & Styles handbook.

disabling core blocks: Reduce the Surface Area of the System

disabling core blocks: Reduce the Surface Area of the System

A design system fails when editors can reach 90 core blocks that have no token-aware defaults. In 2026 there are two supported layers. Declarative: "allowedBlockTypes": ["core/paragraph", "core/heading", "core/group", "core/image", "core/button", "core/columns", "core/template-part"] under settings.allowedBlockTypes in theme.json (schema v3), which core passes into getAllowedBlockTypes() for the editor. Runtime: the allowed_block_types_all filter in functions.php, which is still the right place for role-based or post-type-based exceptions.

// /wp-content/themes/token-lab/functions.php
add_filter( 'allowed_block_types_all', function ( $allowed, $context ) {
    $base = array(
        'core/paragraph', 'core/heading', 'core/group', 'core/image',
        'core/button', 'core/buttons', 'core/columns', 'core/column',
        'core/list', 'core/list-item', 'core/quote', 'core/separator',
        'core/template-part', 'core/site-title', 'core/navigation',
    );
    if ( $context->post && 'page' === $context->post->post_type ) {
        $base[] = 'core/pullquote';
    }
    return current_user_can( 'manage_options' ) ? true : $base;
}, 10, 2 );

// Also unregister the block assets you truly never ship:
add_action( 'init', function () {
    wp_deregister_style( 'wp-block-verse' );
} );

Verify in the editor by typing / in an empty paragraph: only whitelisted names appear for non-admin roles. Confirm programmatically with wp eval 'echo json_encode( apply_filters( "allowed_block_types_all", true, new WP_Block_Editor_Context( array( "name" => "core/edit-post" ) ) ) );' and check wp.data.select('core/blocks').getBlockTypes().length in the console. Note that unregister_block_type() on a core server-registered block breaks rendering of existing content: the post shows “Your site doesn’t include support for this block” instead of falling back. Prefer the allowlist, which hides insertion but keeps do_blocks() able to render legacy markup.

Rollback path

Comment out the filter or remove allowedBlockTypes, then wp cache flush. If you already deregistered a style or unregistered a type, restore the init callback and re-run wp block list (WP-CLI 2.12) to confirm registration. Audit orphaned content first with wp db query "SELECT ID, post_title FROM wp_posts WHERE post_content LIKE '%wp:verse%'"; keep the block registered for at least one release while you migrate those posts. A subtle pitfall: admins seeing the full inserter because of the current_user_can() exception will report “the whitelist doesn’t work” — test with an Editor account, not an Administrator. The block filters reference documents the filter signature used above.

Production Verification, Caching and Deployment Notes for 2026

Global styles are computed once per request and cached by WP_Theme_JSON_Resolver in the theme_json group; on Redis or Memcached setups a deploy that only replaces theme.json can serve yesterday’s CSS. Make wp cache flush plus a versioned style.css Version: bump part of every release. On multisite, user global styles are per-site wp_global_styles posts, so run the resolver assertion against each blog with wp site list --field=url | xargs -I{} wp eval '...' --url={}.

Child themes merge rather than replace: a child theme.json with only styles.color.text inherits the parent’s entire settings tree. That is usually desired, but it means a parent slug rename still breaks child content. Pin "version": 3 explicitly; WordPress 6.9 still accepts v2 and silently ignores newer keys such as allowedBlockTypes and refined spacingScale fields when the declared version is old. Finally, diff the generated stylesheet between staging and production: wp eval 'echo wp_get_global_stylesheet();' | sha256sum on both hosts should match byte-for-byte when the same theme commit and no user overrides are active. More implementation notes are collected in our technical column index.

Frequently asked questions

Does changing a theme.json token update content already saved in the database?

Yes. Posts store slugs like textColor: "ink", and the resolver emits the new hex into --wp--preset--color--ink at render time. No search-replace is needed unless you renamed the slug itself or allowed custom hex values.

Why do core colors still appear after I defined my own palette?

You left settings.color.defaultPalette unset, so it defaults to true. Add "defaultPalette": false alongside "custom": false, flush the object cache, and reopen the Site Editor to clear its in-memory settings.

Can I use theme.json in a classic PHP theme in WordPress 6.9?

Partially. Global styles CSS and presets are emitted, but block templates, the Site Editor and layout-driven fluid bounds require a block theme. Use it for tokens only, and keep PHP templates as the source of markup.

Action checklist

  • Commit a schema v3 theme.json with role-based slugs, defaultPalette: false, defaultFontSizes: false and defaultSpacingSizes: false.
  • Keep every styles value as a var(--wp--preset--*) reference; lint JSON and assert resolver output in CI.
  • Verify computed spacing steps, clamp() output and editor/front-end parity at 360px, 768px and 1600px.
  • Apply an allowed_block_types_all allowlist, test with an Editor account, and audit legacy block markup before unregistering anything.
  • Ship with wp cache flush, a theme version bump, and a one-release slug alias plan for any rename.

Need this audited or rolled out across a client portfolio? Our theme development team builds token-driven block themes with CI verification and safe rollbacks. See all WordPress services.

References & further reading

Need WordPress Development?

Free proposal and quote within 24 hours — we serve clients worldwide.

QQ