
Table of Contents
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.
Get a Quote
Email + requirement + budget range — we reply with a quote within 24 hours.
Practical rules
- Set
settings.color.customandsettings.typography.customFontSizetofalseto lock the palette and type scale. - Use
styles.blocks.core/group.spacing.blockGapfor section rhythm instead of per-block margin overrides. - Keep
styles.cssfor 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

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.
- 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. - Filter the registry in PHP with
add_filter('allowed_block_types_all', ...)for the server-side allowlist, or in JS withwp.blocks.unregisterBlockType('core/cover')hooked towp.domReady. - Disable presets you do not want exposed via
settings.color.custom,settings.color.customGradientandsettings.spacing.units. - Enqueue the editor script from the theme with
add_action('enqueue_block_editor_assets', ...)and confirm the handle loads before the editor boots. - 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. - 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
| Check | Command or location | Expected |
|---|---|---|
| Tokens present | :root in DevTools | --wp--preset--color--primary listed |
| Stylesheet emitted | #global-styles-inline-css | One tag, no duplicates |
| Editor palette | Block sidebar, Colour | Only your slugs, no custom picker |
| Fluid output | Search clamp( in rendered CSS | One 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: 3and the schema URL, then validate the JSON with a CLI one-liner in CI. - Lock the palette and type scale with
custom: falsebefore 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.
