Color management is one of the most powerful features of WordPress block themes. With theme.json, you can define custom color palettes that appear throughout the block editor, ensuring every page, post, and template uses your exact brand colors, without relying on custom CSS or page builders.

This guide covers everything from basic palette setup to advanced techniques like duotone filters, gradient presets, dark mode variations, per-block restrictions, and accessibility testing for color contrast.

Before theme.json existed, this kind of consistency was mostly aspirational. A classic PHP theme might expose a handful of color options through the Customizer, but nothing stopped a page builder or a raw HTML block from introducing an arbitrary hex value that had never been approved by anyone. Every color decision lived in whatever tool happened to touch that particular block, which meant brand consistency depended entirely on discipline rather than anything the software actually enforced. Theme.json changes that relationship: the palette is defined once, centrally, and every part of the editing experience, from the color picker to the default styles a fresh block inherits, reads from that single source.

How Color Palettes Work in theme.json

When you define a color palette in theme.json, WordPress does three things automatically:

  1. Generates CSS custom properties (variables) for each color, accessible anywhere in your theme
  2. Populates the color picker in the block editor with your custom palette
  3. Overrides WordPress default colors so editors only see your brand colors (unless you explicitly keep the defaults)

This means your content creators can only choose from approved brand colors when styling blocks, eliminating off-brand color usage across the entire site. It’s worth understanding why this matters beyond convenience: before block themes, keeping color usage consistent meant either training every editor on a brand guide they’d inevitably forget, or writing custom CSS to override whatever color they’d picked. A restricted palette moves that enforcement into the tool itself, so consistency doesn’t depend on anyone remembering a rule.

Basic Color Palette Setup

Open your theme’s theme.json file (create one if it doesn’t exist) and add your palette under settings.color.palette:

{
  "$schema": "https://schemas.wp.org/trunk/theme.json",
  "version": 3,
  "settings": {
    "color": {
      "palette": [
        {
          "slug": "primary",
          "color": "#1a56db",
          "name": "Primary"
        },
        {
          "slug": "primary-dark",
          "color": "#1e3a5f",
          "name": "Primary Dark"
        },
        {
          "slug": "secondary",
          "color": "#7c3aed",
          "name": "Secondary"
        },
        {
          "slug": "accent",
          "color": "#f59e0b",
          "name": "Accent"
        },
        {
          "slug": "surface",
          "color": "#f8fafc",
          "name": "Surface"
        },
        {
          "slug": "text",
          "color": "#1e293b",
          "name": "Text"
        },
        {
          "slug": "text-light",
          "color": "#64748b",
          "name": "Text Light"
        },
        {
          "slug": "white",
          "color": "#ffffff",
          "name": "White"
        }
      ]
    }
  }
}

Each color needs three properties:

  • slug, Machine-readable identifier used in CSS variable names and class names
  • color, The hex, RGB, or HSL color value
  • name, Human-readable label shown in the editor

The version field at the top matters more than it looks like it should. Theme.json has gone through several schema versions as WordPress core added new capabilities, and pointing your $schema reference at the trunk schema, as shown above, is what gives you IDE autocomplete and validation in editors like VS Code once you have the right extension installed. Getting the version number wrong, or omitting it, doesn’t always break your site outright, but it can silently disable newer settings you’re relying on, which is a frustrating thing to debug when a feature that should work simply does nothing.

Generated CSS variables

WordPress automatically creates CSS custom properties from your palette:

--wp--preset--color--primary: #1a56db;
--wp--preset--color--primary-dark: #1e3a5f;
--wp--preset--color--secondary: #7c3aed;
--wp--preset--color--accent: #f59e0b;

Use these variables anywhere in your theme CSS:

.wp-block-button__link {
  background-color: var(--wp--preset--color--primary);
  color: var(--wp--preset--color--white);
}

.wp-block-button__link:hover {
  background-color: var(--wp--preset--color--primary-dark);
}

Because these are real CSS custom properties rather than a build-time substitution, they update live if you switch a style variation, and they’re inspectable in browser devtools exactly like any other CSS variable, which makes debugging a color mismatch considerably easier than it was with the equivalent Customizer-based systems classic themes relied on.

Controlling Default vs Custom Palettes

By default, WordPress shows both its default palette and your custom palette in the editor. To show only your brand colors:

{
  "settings": {
    "color": {
      "defaultPalette": false,
      "palette": [ ... ]
    }
  }
}

Setting defaultPalette to false removes WordPress core colors (black, cyan, purple, etc.) from the picker. This is strongly recommended for brand consistency.

You can also disable custom colors entirely, forcing editors to use only your palette:

{
  "settings": {
    "color": {
      "custom": false,
      "defaultPalette": false,
      "palette": [ ... ]
    }
  }
}

Whether to lock things down this tightly depends on who’s actually editing the site. For a client site where the editor is a marketing team member without design training, disabling custom colors entirely is usually the right call, since it removes the temptation to pick an off-brand shade that happens to look fine in isolation but clashes with everything around it. For a theme meant to be flexible across many different users, like a theme you’re distributing publicly, leaving custom colors enabled gives site owners room to adapt the palette to their own brand without editing code.

Gradient Presets

Define custom gradients alongside your color palette:

{
  "settings": {
    "color": {
      "gradients": [
        {
          "slug": "primary-to-secondary",
          "gradient": "linear-gradient(135deg, #1a56db 0%, #7c3aed 100%)",
          "name": "Primary to Secondary"
        },
        {
          "slug": "warm-sunset",
          "gradient": "linear-gradient(180deg, #f59e0b 0%, #ef4444 100%)",
          "name": "Warm Sunset"
        },
        {
          "slug": "subtle-surface",
          "gradient": "linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)",
          "name": "Subtle Surface"
        }
      ]
    }
  }
}

Like palette colors, set defaultGradients: false to remove WordPress defaults and customGradient: false to prevent custom gradient creation. It’s worth building gradients directly from your palette’s existing color values, as shown above, rather than introducing entirely new hex codes into the gradient stops. That keeps a gradient background feeling like a natural extension of your brand colors rather than an unrelated decorative flourish, and it means a future rebrand only has to touch the palette, not every gradient definition separately.

Duotone Presets

Duotone filters apply a two-tone color effect to images and cover blocks. Define them in your palette:

{
  "settings": {
    "color": {
      "duotone": [
        {
          "slug": "brand-duotone",
          "colors": ["#1e3a5f", "#f59e0b"],
          "name": "Brand Duotone"
        },
        {
          "slug": "mono-blue",
          "colors": ["#1a56db", "#bfdbfe"],
          "name": "Mono Blue"
        }
      ],
      "defaultDuotone": false
    }
  }
}

Duotone arrays always take exactly two colors: the shadow (dark) color and the highlight (light) color. WordPress maps image tones to these two colors for a branded image effect. It’s a subtle tool, but it’s a genuinely effective way to make stock photography or user-submitted images feel consistent with the rest of a brand-heavy site, since even an unrelated photo takes on your palette’s tonal range once the duotone filter is applied. Use it sparingly on real photography, since a heavy duotone can obscure the actual subject of an image if the two colors chosen are too close in value.

Beyond the palette itself, theme.json’s styles.elements object is where you assign specific palette colors to specific HTML elements globally, rather than leaving every block to inherit whatever default color the theme ships with. This is where a palette actually becomes a coherent visual system instead of just a list of available swatches:

{
  "styles": {
    "color": {
      "background": "var(--wp--preset--color--surface)",
      "text": "var(--wp--preset--color--text)"
    },
    "elements": {
      "link": {
        "color": {
          "text": "var(--wp--preset--color--primary)"
        },
        ":hover": {
          "color": {
            "text": "var(--wp--preset--color--primary-dark)"
          }
        }
      },
      "button": {
        "color": {
          "background": "var(--wp--preset--color--primary)",
          "text": "var(--wp--preset--color--white)"
        }
      },
      "heading": {
        "color": {
          "text": "var(--wp--preset--color--text)"
        }
      }
    }
  }
}

Setting global defaults this way means a fresh paragraph, heading, or button block picks up your brand colors automatically the moment it’s inserted, with no manual color selection required from whoever is writing the content. Individual blocks can still override these defaults from the editor’s block-level color controls when a specific instance genuinely needs to differ.

The :hover state shown for the link element above is worth calling out specifically, since it’s easy to define a link’s resting color and forget the interactive states entirely, leaving WordPress to fall back on browser defaults or whatever the block’s own CSS happens to specify. A visited-state definition is also supported the same way, and setting it deliberately rather than leaving it to chance matters more than it might seem for any content-heavy site, since link color and its state changes are one of the more consistently noticed details across a whole site’s pages.

Restricting Colors Per Block

A single global palette isn’t always the right level of restriction. A Button block and a Heading block often have very different color needs: a button typically only makes sense in one or two brand colors that meet contrast requirements against its background, while a heading might reasonably appear in any text color in your palette. Theme.json lets you scope settings to a specific block under settings.blocks:

{
  "settings": {
    "blocks": {
      "core/button": {
        "color": {
          "palette": [
            {
              "slug": "primary",
              "color": "#1a56db",
              "name": "Primary"
            },
            {
              "slug": "secondary",
              "color": "#7c3aed",
              "name": "Secondary"
            }
          ],
          "custom": false
        }
      }
    }
  }
}

This narrows the Button block’s color picker to just two options while leaving your full palette available everywhere else. It’s a small addition that prevents a specific, common mistake: an editor picking a low-contrast or off-brand color for the one element on the page whose entire job is to draw the eye and get clicked.

Style Variations for Dark Mode

Block themes support style variations, alternative theme.json configurations that users can switch between in the Site Editor. This is how you implement dark mode:

Create a file at styles/dark.json in your theme:

{
  "$schema": "https://schemas.wp.org/trunk/theme.json",
  "version": 3,
  "title": "Dark Mode",
  "settings": {
    "color": {
      "palette": [
        {
          "slug": "primary",
          "color": "#60a5fa",
          "name": "Primary"
        },
        {
          "slug": "surface",
          "color": "#0f172a",
          "name": "Surface"
        },
        {
          "slug": "text",
          "color": "#e2e8f0",
          "name": "Text"
        },
        {
          "slug": "text-light",
          "color": "#94a3b8",
          "name": "Text Light"
        }
      ]
    }
  },
  "styles": {
    "color": {
      "background": "var(--wp--preset--color--surface)",
      "text": "var(--wp--preset--color--text)"
    }
  }
}

Keep the same slugs as your main palette but change the color values. Since CSS variables reference slugs, all your theme styles automatically adapt to the new colors when the variation is activated. Notice that the dark variation’s primary blue, #60a5fa, is noticeably lighter than the light mode primary, #1a56db. That’s deliberate rather than arbitrary: a saturated, mid-tone blue that reads clearly against a white background tends to look muddy and low-contrast against a near-black one, so dark mode palettes generally need lighter, slightly desaturated versions of your brand colors rather than a straight reuse of the light mode values.

Color Palette Best Practices

  • Use semantic slug names (primary, secondary, surface, text) instead of color names (blue, purple). This makes dark mode and rebranding possible without changing template code.
  • Include 6-10 colors maximum. Too many choices lead to inconsistent designs. Include: primary, secondary, accent, surface/background, text (dark and light), and white/black.
  • Test contrast ratios. Every text color + background color combination must meet WCAG 2.1 AA standards (4.5:1 for normal text, 3:1 for large text). Use WebAIM’s Contrast Checker.
  • Disable default palettes in production themes. This prevents editors from using off-brand colors.
  • Document your palette for content editors. Create a patterns page or documentation that shows approved color combinations.

The semantic naming point is worth dwelling on, because it’s the single most common mistake in palettes built without much planning. A slug like blue-600 works fine until the day a rebrand replaces that blue with a green, and now every template referencing --wp--preset--color--blue-600 is technically calling a color that’s no longer blue at all. A slug like primary never has that problem, since the name describes the color’s role rather than its literal appearance, and the dark mode example above only works cleanly because both variations share the same role-based slugs.

Testing Color Accessibility

After defining your palette, test every expected text/background combination:

  • Text on Surface: #1e293b on #f8fafc, ratio 14.3:1 (AAA pass)
  • White on Primary: #ffffff on #1a56db, ratio 5.9:1 (AA pass)
  • White on Secondary: #ffffff on #7c3aed, ratio 5.4:1 (AA pass)
  • Text Light on Surface: #64748b on #f8fafc, ratio 4.7:1 (AA pass)

If any combination fails, adjust the color values until all combinations pass AA or higher. This is especially important for button text, link text, and body copy. It’s worth testing this early in the palette design process rather than after the theme is otherwise finished, because fixing a contrast failure by adjusting one color often ripples into every other combination that color participates in. A secondary color chosen purely for how it looks in a swatch, without checking it against your actual text and background colors, is a common source of last-minute rework.

Common Mistakes That Cause a Palette Not to Show Up

A handful of issues account for most of the “I set my palette but the editor still shows the old colors” reports. Browser and object caching are the usual first suspect: theme.json changes sometimes need a hard refresh, and if the site runs a full-page cache or a persistent object cache, the cached page can keep serving stale CSS variables until that cache clears. A malformed JSON file is the second common cause, often a trailing comma or a missing bracket, which WordPress will generally fail silently on rather than throwing a visible error, so validating your theme.json through a JSON linter before troubleshooting further saves real time. And a child theme’s own theme.json, even a mostly empty one, will merge with and can unintentionally override specific keys from the parent’s palette, which is worth checking if you’re seeing an unexpected mix of old and new colors rather than a clean switch.

Migrating an Existing Site to a New Palette

Rebranding a live site is a different problem than setting up a palette from scratch, because content that already exists on the site was styled using the old slugs and old values. If you keep the same semantic slugs and only change the color values behind them, as recommended above, a rebrand becomes almost entirely mechanical: update the hex values in theme.json, and every block that referenced var(--wp--preset--color--primary) picks up the new brand color automatically without touching a single page.

The harder case is content where an editor picked a specific hex value directly through the custom color picker rather than choosing from the palette, which theme.json can’t retroactively fix since that color was saved as a literal value in the block’s stored attributes rather than as a reference to a slug. This is the practical argument for disabling custom colors on a client site well before a rebrand becomes necessary: every color chosen from the restricted palette stays reference-based and rebrand-friendly, while every custom color picked outside it becomes technical debt that has to be found and fixed by hand later.

Frequently Asked Questions

Can I use RGB or HSL instead of hex in theme.json?

Yes. The color property accepts any valid CSS color value: hex (#1a56db), RGB (rgb(26, 86, 219)), HSL (hsl(221, 83%, 48%)), or named colors.

How do I add colors for specific blocks only?

Use block-level settings in theme.json. Under settings.blocks, you can define per-block palettes. For example, give the Button block a restricted set of colors while allowing more options for Headings, as shown in the per-block example above.

Will my custom palette work with child themes?

Yes. Child themes can extend or override the parent’s palette by defining their own theme.json. WordPress merges theme.json files with child theme values taking priority, which is powerful but also the source of the override confusion mentioned above, so keep a child theme’s theme.json as small and intentional as possible rather than duplicating the parent’s full palette unnecessarily.