Home Blog Article

Building a Design System with theme.json Tokens

Building a Design System with theme.json Tokens

A design system in WordPress is built by declaring theme.json design tokens as presets under settings, then consuming them through styles and the var(--wp--preset--*) custom properties. It is done right when the block editor shows your palette, font sizes and spacing in the inspector, the front end renders identical values, and no hard-coded hex codes remain in your CSS beyond the token layer.

How theme.json tokens work internally

Since WordPress 5.9, theme.json is parsed by WP_Theme_JSON_Resolver, merged with core and block defaults, then flattened into a single stylesheet printed in the site head. Presets generate CSS custom properties named --wp--preset--color--slug, --wp--preset--font-size--slug, --wp--preset--spacing--slug, plus matching utility classes such as .has-slug-color and .has-slug-background-color. Because values pass through PHP sanitizers, unsupported keys are silently dropped rather than throwing an error, so you verify changes by inspecting the rendered global-styles-inline-css block instead of trusting the file alone.

The minimum runnable example is a child theme with theme.json in the theme root. WordPress loads it automatically when the file is absent in the parent or merged when present in both; as of 2026, version 3 is the current schema, and you declare it at the top with "$schema" pointing to https://schemas.wp.org/wp/6.8/theme.json for editor autocomplete.

{
  "$schema": "https://schemas.wp.org/wp/6.8/theme.json",
  "version": 3,
  "settings": {
    "appearanceTools": true,
    "useRootPaddingAwareAlignments": true,
    "layout": { "contentSize": "720px", "wideSize": "1200px" },
    "color": {
      "custom": false,
      "customDuotone": false,
      "palette": [
        { "slug": "base",     "color": "#ffffff", "name": "Base" },
        { "slug": "contrast", "color": "#111111", "name": "Contrast" },
        { "slug": "primary",  "color": "#1f5eff", "name": "Primary" }
      ]
    },
    "typography": {
      "fluid": true,
      "fontFamilies": [
        { "slug": "body", "name": "Body", "fontFamily": "Inter, system-ui, sans-serif" }
      ],
      "fontSizes": [
        { "slug": "small", "size": "0.875rem", "name": "Small" },
        { "slug": "medium", "size": "1.125rem", "name": "Medium" },
        { "slug": "x-large", "size": "2.5rem", "name": "Extra Large" }
      ]
    },
    "spacing": {
      "units": [ "px", "rem", "%", "vw" ],
      "spacingScale": { "operator": "*", "increment": 1.5, "steps": 7, "mediumStep": 1.5 },
      "spacingSizes": [
        { "slug": "30", "size": "1rem", "name": "1" },
        { "slug": "50", "size": "3rem", "name": "3" }
      ],
      "padding": true, "margin": true, "blockGap": true
    }
  },
  "styles": {
    "color": { "background": "var(--wp--preset--color--base)", "text": "var(--wp--preset--color--contrast)" },
    "typography": { "fontFamily": "var(--wp--preset--font-family--body)", "fontSize": "var(--wp--preset--font-size--medium)", "lineHeight": 1.6 },
    "spacing": { "padding": { "left": "var(--wp--preset--spacing--50)", "right": "var(--wp--preset--spacing--50)" } },
    "elements": { "link": { "color": { "text": "var(--wp--preset--color--primary)" } } }
  }
}

Verify with wp theme list, reload the editor, and open the browser inspector on any paragraph. You should see the custom properties on :root and a style tag with id global-styles-inline-css. If the palette does not appear, run wp cache flush and confirm the file is valid JSON with php -r 'json_decode(file_get_contents("theme.json"), true);'.

settings vs styles: the separation that matters

settings controls what the editor is allowed to offer — which palettes, font sizes, gradients, spacing units and block tools exist — while styles decides what the site actually renders, both globally and per block. Presets declared in settings become tokens; the same token consumed in styles produces the real CSS. This split lets a design system expose choices without forcing them, and it is why a token added only to styles will render but never appear in the sidebar. For larger builds, treat token authoring as an engineering discipline rather than a styling afterthought; our breakdown of custom WordPress development services covers how to scope that work before a theme is written.

Practical rules

  • Set settings.color.custom and settings.typography.customFontSize to false to lock the palette and type scale.
  • Use styles.blocks.core/group.spacing.blockGap for section rhythm instead of per-block margin overrides.
  • Keep styles.css for layout primitives only; any colour or type value in CSS should reference a preset variable.

slug naming: stable, lowercase, prefixable

The slug is the contract between theme.json and every file that consumes a token — the generated CSS variable, the utility class, and the value stored inside saved post content. Renaming a slug does not migrate existing blocks; posts keep the old class name and lose their styling. Slugs must be lowercase alphanumeric with hyphens, and as of 2026 numeric-only slugs are permitted in the spacing scale but discouraged elsewhere because the resulting variables read poorly. Adopt a prefix such as brand- for client palettes when the theme is reused across projects, and never encode presentational words like blue if the colour could change — use primary, accent, muted.

spacingScale and fluid typography

settings.spacing.spacingScale generates a stepped scale from operator, increment, steps and mediumStep, producing slugs like 20, 30, 40 that map to consistent multiples. Override it with spacingSizes when you need a named, non-mathematical scale. On the typography side, settings.typography.fluid: true makes WordPress emit clamp() values computed from fluid.min, fluid.max and fluid.minViewportWidth/maxViewportWidth, so a medium size defined as 1.125rem becomes something like clamp(1rem, 1rem + ((1vw - 0.48rem) * 1.4), 1.125rem) in the output. Inside fluid you can set a custom min per size, or add explicit fluid objects to individual fontSizes entries. Always test at 360px and 1440px viewports; a misconfigured viewport range is the most common cause of text that never grows.

Teams shipping storefronts on top of a token system should also settle performance budgets early — the cost model in WordPress ecommerce website pricing is a useful sanity check when a design system expands scope.

Process to disable core blocks and preset features safely

Process to disable core blocks and preset features safely

Disabling blocks is done from the client side or server side, and both need verification because a block hidden in the editor remains registered in REST unless you unregister it. Follow this order and keep a rollback path.

  1. Audit usage: run wp db query "SELECT post_content FROM wp_posts WHERE post_content LIKE '%wp:paragraph%' LIMIT 5;" to see which core blocks are actually in content.
  2. Filter the registry in PHP with add_filter('allowed_block_types_all', ...) for the server-side allowlist, or in JS with wp.blocks.unregisterBlockType('core/cover') hooked to wp.domReady.
  3. Disable presets you do not want exposed via settings.color.custom, settings.color.customGradient and settings.spacing.units.
  4. Enqueue the editor script from the theme with add_action('enqueue_block_editor_assets', ...) and confirm the handle loads before the editor boots.
  5. Verify by opening the inserter and checking the Network tab for wp/v2/block-types, then edit a saved post containing a disabled block — it must still render on the front end.
  6. Roll back by removing the filter or the enqueue; no content is destroyed, because disabling only affects authoring UI.

If a block is being replaced rather than removed, our guide to building a custom WordPress block shows how to register a token-aware replacement with block.json and no hard-coded values.

Verification and pitfalls

CheckCommand or locationExpected
Tokens present:root in DevTools--wp--preset--color--primary listed
Stylesheet emitted#global-styles-inline-cssOne tag, no duplicates
Editor paletteBlock sidebar, ColourOnly your slugs, no custom picker
Fluid outputSearch clamp( in rendered CSSOne clamp per fluid size

Common pitfalls: trailing commas and comments make the JSON invalid and WordPress falls back to defaults silently; a slug collision between two entries overwrites the earlier token; appearanceTools left off hides many spacing controls even though the scale exists; and caching plugins can serve a stale global-styles-inline-css, which is why wp cache flush belongs in your troubleshooting checklist.

Action checklist

  • Declare version: 3 and the schema URL, then validate the JSON with a CLI one-liner in CI.
  • Lock the palette and type scale with custom: false before handing the theme to editors.
  • Adopt prefixed, presentational slugs and treat them as immutable public API.
  • Enable fluid typography and test at 360px and 1440px on every release.
  • Disable blocks through an allowlist filter and keep the removal in version control for instant rollback.

Need a token architecture that survives rebrands and migrations? Our theme development team builds and audits theme.json design systems for agencies and in-house teams.

References & further reading

Need WordPress Development?

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

QQ