Dark mode for Elementor V4: global variables and no flash
By Rolando Escobar, WordPress developer for agencies · · 5 min read
Elementor V4 gives you global variables but no dark mode. The quick fixes either break your palette (a plugin that inverts colors) or flash the light theme before switching (a script that runs after the page has painted).
This is how I added dark mode to rolandowp.com without either problem. The variables do the switching, a few lines in <head> pick the mode before the first paint, and the toggle in the header is a real button with aria-pressed. It follows the operating system until the visitor chooses, and axe-core reports zero WCAG 2.1 AA violations in both modes.
Where Elementor V4 prints your variables
V4 global variables end up as CSS custom properties on :root, inside the kit’s generated stylesheet (on my site, uploads/elementor/css/post-46.css, where 46 is the kit ID). Each property is named after the variable’s label, so a color labeled “ink” becomes --ink, and “surface” becomes --surface.
That makes dark mode mostly a matter of redefining those properties under a selector that wins. html[data-theme="dark"] has a higher specificity than :root (an element plus an attribute, against a single pseudo-class), so the override applies no matter which stylesheet loads last. Because the names come from the labels, check the kit CSS after you rename a variable in the editor: the override has to use the new name.
Redefine the tokens, not the components
The whole dark palette lives in one rule in the child theme’s style.css. Anything that already uses the variables switches on its own:
/* Dark palette: same names as the Elementor kit variables, new values. */
html[data-theme="dark"] {
color-scheme: dark;
--surface: #0F172A;
--surface-soft: #131C31;
--ink: #E6EDF7;
--muted: #A9B6CC;
--line: #26334D;
--line-strong: #62728E;
--brand: #8AB4FF;
--brand-hover: #B3CDFF;
--success: #34D399;
--rw-focus: #8AB4FF;
--rw-on-brand: #0B1220; /* Text on light-blue buttons. */
}
body {
background-color: var(--surface, #FFFFFF);
}
html[data-theme="dark"] body {
color: var(--ink);
}
/* Primary buttons turn light blue in dark mode, so their text turns dark. */
html[data-theme="dark"] .elementor .btn-primary {
color: var(--rw-on-brand);
}
color-scheme: dark tells the browser to draw native form controls, scrollbars and the default canvas in dark colors too (see MDN on color-scheme). The primary buttons are the one component that needs its own rule: a dark-blue button with white text becomes a light-blue button with dark text.
Replace every hard-coded color
Variables switch; hex values don’t. Any color written directly in CSS stays light in dark mode, which is how you end up with dark text on a dark background in one forgotten corner. I went through the child theme and replaced the hex colors with a variable plus a fallback:
/* Before: stays the same in both modes. */
.rw-facts dt {
color: #0F172A;
}
.rw-facts__row {
border-bottom: 1px solid #DCE3EE;
}
/* After: follows the active palette. The fallback only applies if the variable is missing. */
.rw-facts dt {
color: var(--ink, #0F172A);
}
.rw-facts__row {
border-bottom: 1px solid var(--line, #DCE3EE);
}
To find what’s left, grep -nE '#[0-9A-Fa-f]{3,8}\b' style.css | grep -v 'var(--' lists every hex color that isn’t inside a var() fallback; what’s left should be token definitions. The same rule applies inside Elementor: when you set a color on a widget, pick the variable, not a raw color, or that widget won’t switch either.
Pick the mode before the first paint
The flash happens when the page paints with the light palette and a script switches it afterward. The fix is to decide in <head>, before the browser renders anything. A stored choice wins; otherwise the site follows prefers-color-scheme:
<?php
/**
* Resolve the color mode in <head>, before anything paints.
* A stored choice wins; otherwise follow the operating system.
*/
function rw_theme_mode_head_script() {
$js = <<<'JS'
(() => {
let mode = null;
try {
mode = localStorage.getItem('rw-theme');
} catch {
// Storage can be blocked; fall back to the system setting.
}
if (mode !== 'light' && mode !== 'dark') {
mode = window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', mode);
})();
JS;
wp_print_inline_script_tag( $js );
}
add_action( 'wp_head', 'rw_theme_mode_head_script', 0 );
Priority 0 puts it near the top of wp_head. It’s inline on purpose: an external file would add a blocking request for a few hundred bytes. The try block matters because some browsers throw when storage is blocked. And since the decision happens in the browser, full-page caching keeps working; a cookie read on the server would force a separate cached copy per mode.
An accessible toggle
The toggle is a shortcode, [rw_theme_toggle], placed in the header. It prints a real <button> with a fixed accessible name and aria-pressed for the state:
<?php
function rw_theme_toggle_shortcode() {
$es = function_exists( 'pll_current_language' ) && 'es' === pll_current_language();
$label = $es ? 'Modo oscuro' : 'Dark mode';
$svg = '<svg class="rw-theme-toggle__icon rw-theme-toggle__icon--%s" aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">%s</svg>';
$moon = sprintf( $svg, 'moon', '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>' );
$sun = sprintf( $svg, 'sun', '<circle cx="12" cy="12" r="4.5"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>' );
return sprintf(
'<button type="button" class="rw-theme-toggle" aria-pressed="false">%s%s<span class="rw-sr-only">%s</span></button>',
$moon,
$sun,
esc_html( $label )
);
}
add_shortcode( 'rw_theme_toggle', 'rw_theme_toggle_shortcode' );
Screen readers announce it along the lines of “Dark mode, toggle button, not pressed”, then “pressed” after a click. The name never changes; only the state does. Changing both (a label that flips to “Light mode” while aria-pressed also flips) makes the control contradict itself. The WAI-ARIA button pattern covers toggle buttons in detail.
The icons are decorative, so they carry aria-hidden, and CSS shows the moon in light mode and the sun in dark mode. The button is 44×44px on desktop and 40×40px on phones, with a border of at least 3:1 so it reads as a control. WCAG 2.1 AA has no target-size rule, but both sizes clear the 24×24px minimum in WCAG 2.2’s 2.5.8 Target Size (Minimum).
The footer script handles clicks, keeps aria-pressed in sync and follows OS changes until the visitor picks a mode:
<?php
function rw_theme_mode_footer_script() {
$js = <<<'JS'
(() => {
const root = document.documentElement;
const buttons = document.querySelectorAll('.rw-theme-toggle');
const stored = () => {
try {
return localStorage.getItem('rw-theme');
} catch {
return null;
}
};
const sync = () => {
const pressed = root.getAttribute('data-theme') === 'dark' ? 'true' : 'false';
buttons.forEach((button) => button.setAttribute('aria-pressed', pressed));
};
buttons.forEach((button) => {
button.addEventListener('click', () => {
const next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
try {
localStorage.setItem('rw-theme', next);
} catch {
// Not saved; the choice still applies to this page.
}
sync();
});
});
// Follow the system setting until the visitor makes a choice.
window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener('change', (event) => {
const choice = stored();
if (choice !== 'light' && choice !== 'dark') {
root.setAttribute('data-theme', event.matches ? 'dark' : 'light');
sync();
}
});
sync();
})();
JS;
wp_print_inline_script_tag( $js );
}
add_action( 'wp_footer', 'rw_theme_mode_footer_script', 100 );
Logos and third-party widgets
The regular logo is made for light backgrounds, so dark mode needs a second version. Both logos sit in the header, and CSS shows one or the other: img[src*="-on-dark"] is hidden by default and shown under html[data-theme="dark"], while the regular logo does the opposite. A <picture> element with a prefers-color-scheme media query looks cleaner, but it only follows the OS and ignores the toggle. The hidden logo uses display: none, so screen readers don’t announce it twice.
Third-party plugins are the other gap. My cookie banner, CookieAdmin, sets inline styles from JavaScript, and an inline style beats any selector. So its dark overrides need !important, scoped to the banner’s own classes.
Check the dark palette for contrast
A dark palette fails in different places than a light one: muted text drifts too dark, borders vanish and saturated brand colors vibrate. Every pair was checked against AA (4.5:1 for text, 3:1 for borders and controls):
| Pair | Colors | Ratio |
|---|---|---|
| Body text on surface | #E6EDF7 on #0F172A |
15.15:1 |
| Muted text on soft surface | #A9B6CC on #131C31 |
8.28:1 |
| Links on surface | #8AB4FF on #0F172A |
8.55:1 |
| Button text on light blue | #0B1220 on #8AB4FF |
8.96:1 |
| Success text on surface | #34D399 on #0F172A |
9.29:1 |
| Field and toggle borders | #62728E on #0F172A |
3.67:1 |
The focus ring switches to the light blue as well, since --rw-focus is one of the redefined tokens.
What to watch out for
- Hard-coded colors creeping back in through widget settings, pasted HTML or a new plugin’s CSS. Run the grep again after each change.
- Inline styles set by plugins. Only
!importantoverrides them, so keep those rules scoped to the plugin’s own classes. - Images with dark text or transparent backgrounds, such as logos and diagrams. They need their own on-dark versions.
- Once a visitor picks a mode, the site stops following the OS. If that matters to your audience, add a “system” option that clears the stored value.
- Renamed variables. The kit prints labels as property names, so a rename silently disconnects your dark overrides.
- Testing only one mode. Run axe and your contrast checks in both, every time the palette changes.
This is the kind of work I take on for clients: Elementor development