If you use Tailwind v4 in a WordPress block theme, your design tokens end up defined twice. The palette, font sizes and spacing live in theme.json, which the Site Editor and Global Styles control. Tailwind wants the same values in an @theme block. Copy the hex codes across by hand and the two drift apart: a client changes the accent colour in Global Styles, and every bg-accent utility still shows the old one.
The snippet below reads the presets WordPress has actually resolved (core, theme and user) and writes a Tailwind v4 @theme inline block. Each token points at WordPress’s own --wp--preset--* CSS variable and copies no value. bg-accent-1, text-x-large, font-body and p-wp-50 then follow theme.json and Global Styles, on the front end and inside the editor iframe, with no rebuild after a colour change.
The snippet
Put it in a small plugin, an mu-plugin or your theme’s functions.php. It also works pasted into one Scripts Organizer PHP block. It adds a WP-CLI command and a function you can call yourself.
<?php
/**
* Plugin Name: DPC theme.json to Tailwind v4 tokens
* Description: Generates a Tailwind v4 @theme block that maps theme.json presets to WordPress CSS variables.
*/
/**
* Tailwind namespace => where the presets live in theme.json settings.
*/
function dpc_tailwind_token_map(): array {
return (array) apply_filters(
'dpc_tailwind_token_map',
array(
'color' => array( 'path' => array( 'color', 'palette' ), 'wp' => 'color', 'prefix' => '' ),
'text' => array( 'path' => array( 'typography', 'fontSizes' ), 'wp' => 'font-size', 'prefix' => '' ),
'font' => array( 'path' => array( 'typography', 'fontFamilies' ), 'wp' => 'font-family', 'prefix' => '' ),
'spacing' => array( 'path' => array( 'spacing', 'spacingSizes' ), 'wp' => 'spacing', 'prefix' => 'wp-' ),
'shadow' => array( 'path' => array( 'shadow', 'presets' ), 'wp' => 'shadow', 'prefix' => '' ),
)
);
}
/**
* Build the Tailwind v4 @theme block from the resolved global settings.
*/
function dpc_tailwind_tokens_css(): string {
$settings = wp_get_global_settings();
$origins = (array) apply_filters( 'dpc_tailwind_token_origins', array( 'theme', 'custom' ) );
$safe = '/^[a-z0-9-]*$/';
$lines = array();
foreach ( dpc_tailwind_token_map() as $namespace => $map ) {
$namespace = (string) $namespace;
$wp_type = (string) ( $map['wp'] ?? '' );
$prefix = (string) ( $map['prefix'] ?? '' );
if ( '' === $namespace || '' === $wp_type || ! preg_match( $safe, $namespace . $wp_type . $prefix ) ) {
continue;
}
[ $group, $key ] = $map['path'];
$presets = $settings[ $group ][ $key ] ?? array();
$tokens = array();
foreach ( $origins as $origin ) {
if ( ! is_array( $presets ) || empty( $presets[ $origin ] ) || ! is_array( $presets[ $origin ] ) ) {
continue;
}
foreach ( $presets[ $origin ] as $preset ) {
// Same slug -> variable conversion core uses (heading1 becomes heading-1).
$slug = _wp_to_kebab_case( (string) ( $preset['slug'] ?? '' ) );
if ( '' === $slug || ! preg_match( $safe, $slug ) ) {
continue;
}
// Later origins (custom) win over earlier ones (theme) for the same slug.
$tokens[ $slug ] = sprintf(
' --%1$s-%2$s%3$s: var(--wp--preset--%4$s--%3$s);',
$namespace,
$prefix,
$slug,
$wp_type
);
}
}
$lines = array_merge( $lines, array_values( $tokens ) );
}
$layout = $settings['layout'] ?? array();
if ( ! empty( $layout['contentSize'] ) ) {
$lines[] = ' --container-content: var(--wp--style--global--content-size);';
}
if ( ! empty( $layout['wideSize'] ) ) {
$lines[] = ' --container-wide: var(--wp--style--global--wide-size);';
}
return '/* Generated from theme.json by dpc_tailwind_tokens_css(). Regenerate when presets change. */' . PHP_EOL
. '@theme inline {' . PHP_EOL
. implode( PHP_EOL, $lines ) . PHP_EOL
. '}' . PHP_EOL;
}
/**
* WP-CLI: wp dpc-tailwind-tokens [--file=<path>]
*/
function dpc_cli_tailwind_tokens( array $args, array $assoc_args ): void {
$css = dpc_tailwind_tokens_css();
$file = (string) ( $assoc_args['file'] ?? '' );
if ( '' === $file ) {
WP_CLI::line( $css );
return;
}
if ( false === file_put_contents( $file, $css ) ) {
WP_CLI::error( sprintf( 'Could not write %s.', $file ) );
}
WP_CLI::success( sprintf( 'Tailwind tokens written to %s.', $file ) );
}
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command(
'dpc-tailwind-tokens',
'dpc_cli_tailwind_tokens',
array(
'shortdesc' => 'Print a Tailwind v4 @theme block built from theme.json presets.',
'synopsis' => array(
array(
'type' => 'assoc',
'name' => 'file',
'optional' => true,
'description' => 'Write to this path instead of STDOUT.',
),
),
)
);
}
Generate the tokens file inside your theme, then build Tailwind as usual:
wp dpc-tailwind-tokens --file=wp-content/themes/my-theme/assets/css/wp-tokens.css
cd wp-content/themes/my-theme
npx @tailwindcss/cli -i ./assets/css/main.css -o ./assets/css/app.css --minify
Import the generated file into your Tailwind entry CSS, right after Tailwind itself:
@import "tailwindcss";
@import "./wp-tokens.css";
@source "../../templates";
@source "../../parts";
@source "../../patterns";
With Twenty Twenty-Five, for example, the file contains lines like --color-accent-1: var(--wp--preset--color--accent-1); and --text-x-large: var(--wp--preset--font-size--x-large);, so class="bg-accent-1 text-x-large p-wp-50 max-w-content" works in your patterns and templates. If Tailwind isn’t in your theme yet, set it up first with Add Tailwind v4 to a WordPress Block Theme Without a Plugin.
How it works
wp_get_global_settings()returns the merged settings, with each preset list split by origin:default,themeandcustom(custom means Global Styles).- Each slug goes through
_wp_to_kebab_case(), the same conversion core uses when it prints the--wp--preset--*variables. That is howheading1becomesheading-1, so the token always points at a variable that exists. @theme inlinetells Tailwind to putvar(--wp--preset--color--accent-1)straight into the utility and skip its own--color-accent-1indirection. The value is resolved at runtime from the stylesheet WordPress prints, which is also loaded in the editor canvas.- Content and wide sizes become
--container-contentand--container-wide, which gives youmax-w-contentandmax-w-wide.
Customise it
- Include core’s default palette and sizes:
add_filter( 'dpc_tailwind_token_origins', fn() => array( 'default', 'theme', 'custom' ) ); - Add or change namespaces through
dpc_tailwind_token_map. For example, drop thewp-spacing prefix if you never use Tailwind’s numeric spacing. - To use only theme.json colours, add
--color-*: initial;at the top of the@themeblock in your entry file, before the import. That removes Tailwind’s default palette.
Gotchas
- Spacing is prefixed (
p-wp-50) on purpose. In Tailwind v4 a--spacing-50token would replace the meaning ofp-50, which normally means 50 × 0.25rem. - Changing a preset’s value in Global Styles needs no rebuild. Adding a new slug does: run the command again, then rebuild Tailwind.
customtokens only exist on sites where a user saved Global Styles. Generate the file against the site whose presets you want, not against a blank local install.- Colours are variables, so opacity modifiers such as
bg-accent-1/50compile tocolor-mix(). That is fine in current browsers, but check it if you support very old ones.
Prefer not to run a Tailwind build at all? Winden compiles Tailwind on the server and works with Gutenberg.
Snippet poll. Should this snippet be in the Scripts Organizer library for one-click import? 10 votes put it there.
0 of 10 votes