CSS custom properties (CSS variables) defined as design tokens in a WordPress theme provide a single source of truth for colors, typography scales, spacing values, and breakpoints — changing one token propagates the update everywhere the token is used without a search-and-replace across dozens of selectors. Design tokens follow a two-tier naming convention: primitive tokens define raw values (--color-blue-600: #2563eb) and semantic tokens reference primitives and communicate purpose (--color-brand-primary: var(--color-blue-600)) — component styles reference only semantic tokens, which allows a complete color scheme swap by redefining semantic tokens without touching primitives or component styles. In WordPress themes, custom properties are declared on :root in a dedicated tokens.css file that is enqueued with wp_enqueue_style() before all other stylesheets, ensuring availability in theme styles, block editor styles, and plugin stylesheets. WordPress 5.8+ block themes define design tokens in theme.json under the settings.color.palette and settings.typography.fontSizes keys — WordPress automatically generates CSS custom properties from these values using the --wp--preset--color--{slug} and --wp--preset--font-size--{slug} naming convention. For classic themes that do not use theme.json, a customizer.php file registers Customizer controls and injects the chosen color values as inline CSS via wp_add_inline_style(), overriding the default token values dynamically without regenerating a CSS file. Dark mode support is implemented by redefining semantic tokens inside a @media (prefers-color-scheme: dark) block or a [data-theme="dark"] attribute selector — only the semantic tokens need to change, all component styles adapt automatically. The env() function is not the same as var() — env() accesses browser environment variables like env(safe-area-inset-bottom) for notched device layouts, while var() accesses author-defined custom properties. The dropdown menu post uses the same token-based color system for hover and focus states — a brand color change in the token layer updates the navigation automatically.
Problem: A WordPress theme defines the same brand color as a hex literal in 40+ selectors across 8 CSS files — a client rebrand requires a time-consuming search-and-replace that misses values in dynamically generated inline styles and plugin stylesheets.
Solution: Replace hardcoded color, spacing, and typography values with a two-tier CSS custom property system — primitive tokens define raw values, semantic tokens reference primitives and communicate purpose, and all component styles reference only semantic tokens.
/* tokens.css — enqueued first, before all theme styles */
:root {
/* ── Primitive tokens ─────────────────────────────────────────── */
--color-blue-50: #eff6ff;
--color-blue-600: #2563eb;
--color-blue-700: #1d4ed8;
--color-gray-100: #f3f4f6;
--color-gray-700: #374151;
--color-gray-900: #111827;
--color-white: #ffffff;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-4xl: 2.25rem;
/* ── Semantic tokens (reference primitives) ────────────────────── */
--color-brand-primary: var(--color-blue-600);
--color-brand-hover: var(--color-blue-700);
--color-brand-light: var(--color-blue-50);
--color-text-primary: var(--color-gray-900);
--color-text-secondary: var(--color-gray-700);
--color-surface: var(--color-white);
--color-surface-alt: var(--color-gray-100);
--font-size-body: var(--text-base);
--font-size-heading-lg: var(--text-2xl);
--font-size-heading-xl: var(--text-4xl);
--spacing-section: var(--space-8);
--spacing-component: var(--space-4);
--spacing-element: var(--space-2);
}
/* ── Dark mode: only semantic tokens change ──────────────────────── */
@media (prefers-color-scheme: dark) {
:root {
--color-text-primary: #f9fafb;
--color-text-secondary: #d1d5db;
--color-surface: #111827;
--color-surface-alt: #1f2937;
}
}
/* ── Component styles reference only semantic tokens ─────────────── */
.btn-primary {
background-color: var(--color-brand-primary);
color: var(--color-surface);
padding: var(--spacing-element) var(--spacing-component);
font-size: var(--font-size-body);
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.btn-primary:hover,
.btn-primary:focus-visible {
background-color: var(--color-brand-hover);
}
// functions.php — enqueue tokens first, then theme styles
add_action('wp_enqueue_scripts', function() {
wp_enqueue_style('mytheme-tokens',
get_theme_file_uri('css/tokens.css'),
[],
wp_get_theme()->get('Version')
);
wp_enqueue_style('mytheme-style',
get_theme_file_uri('css/style.css'),
['mytheme-tokens'], // depends on tokens
wp_get_theme()->get('Version')
);
});
// Customizer: let clients change brand color, injected as inline override
add_action('wp_head', function() {
$brand = get_theme_mod('brand_color', '');
if (!$brand || !preg_match('/^#[0-9a-fA-F]{3,6}$/', $brand)) return;
echo '' . PHP_EOL;
});
NOTE: CSS custom properties are inherited and cascade like any other CSS property — a token redefined on a parent element overrides the :root value for all descendants. This makes scoped theming straightforward (.dark-card { --color-surface: #1f2937; }) but can cause unexpected overrides if a plugin or third-party script also sets a property with the same name on :root. Use a project-specific prefix (e.g., --mytheme-) to avoid collisions.