Todos los artículos

Desarrollo WordPress

Modo oscuro en Elementor V4: variables globales y sin parpadeo

Por Rolando Escobar, desarrollador WordPress para agencias · · 6 min de lectura

Elementor V4 trae variables globales, pero no modo oscuro. Las soluciones rápidas rompen tu paleta (un plugin que invierte los colores) o muestran el tema claro por un instante antes de cambiar (un script que corre cuando la página ya se pintó).

Así agregué el modo oscuro a rolandowp.com sin ninguno de los dos problemas. Las variables hacen el cambio, unas líneas en el <head> eligen el modo antes de pintar y el botón del header es un botón de verdad con aria-pressed. Sigue al sistema operativo hasta que el visitante elige, y axe-core reporta cero errores de WCAG 2.1 AA en los dos modos.

Dónde imprime Elementor V4 tus variables

Las variables globales de V4 terminan como propiedades personalizadas de CSS en :root, dentro de la hoja de estilos que Elementor genera para el kit (en mi sitio, uploads/elementor/css/post-46.css, donde 46 es el ID del kit). Cada propiedad toma el nombre de la etiqueta de la variable: un color con la etiqueta «ink» se vuelve --ink y «surface» se vuelve --surface.

Con eso, el modo oscuro consiste sobre todo en redefinir esas propiedades bajo un selector que gane. html[data-theme="dark"] tiene más especificidad que :root (un elemento más un atributo, contra una sola pseudoclase), así que la redefinición se aplica sin importar qué hoja de estilos cargue al final. Como los nombres salen de las etiquetas, revisa el CSS del kit después de renombrar una variable en el editor: la redefinición tiene que usar el nombre nuevo.

Redefine los tokens, no los componentes

Toda la paleta oscura vive en una sola regla del style.css del tema hijo. Todo lo que ya usa las variables cambia solo:

/* Paleta oscura: los mismos nombres que las variables del kit de Elementor, con otros valores. */
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; /* Texto sobre los botones azul claro. */
}

body {
	background-color: var(--surface, #FFFFFF);
}

html[data-theme="dark"] body {
	color: var(--ink);
}

/* Los botones primarios pasan a azul claro en modo oscuro, así que su texto pasa a oscuro. */
html[data-theme="dark"] .elementor .btn-primary {
	color: var(--rw-on-brand);
}

color-scheme: dark le indica al navegador que dibuje también en oscuro los controles nativos de formulario, las barras de desplazamiento y el lienzo por defecto (ver color-scheme en MDN). Los botones primarios son el único componente que necesita su propia regla: un botón azul oscuro con texto blanco se vuelve un botón azul claro con texto oscuro.

Reemplaza cada color escrito a mano

Las variables cambian; los valores hexadecimales no. Cualquier color escrito directamente en el CSS se queda claro en modo oscuro, y así terminas con texto oscuro sobre fondo oscuro en algún rincón olvidado. Revisé el tema hijo y cambié los colores hexadecimales por una variable con un valor de respaldo:

/* Antes: igual en los dos modos. */
.rw-facts dt {
	color: #0F172A;
}
.rw-facts__row {
	border-bottom: 1px solid #DCE3EE;
}

/* Después: sigue la paleta activa. El respaldo solo se usa si falta la variable. */
.rw-facts dt {
	color: var(--ink, #0F172A);
}
.rw-facts__row {
	border-bottom: 1px solid var(--line, #DCE3EE);
}

Para encontrar lo que queda, grep -nE '#[0-9A-Fa-f]{3,8}\b' style.css | grep -v 'var(--' lista cada color hexadecimal que no está dentro de un respaldo de var(); lo que quede deberían ser solo definiciones de tokens. La misma regla vale dentro de Elementor: cuando le pongas color a un widget, elige la variable y no un color suelto, o ese widget tampoco va a cambiar.

Elige el modo antes de pintar

El parpadeo ocurre cuando la página se pinta con la paleta clara y un script la cambia después. La solución es decidir en el <head>, antes de que el navegador pinte nada. Una elección guardada gana; si no hay, el sitio sigue a prefers-color-scheme:

<?php
/**
 * Resuelve el modo de color en el <head>, antes de pintar nada.
 * Una elección guardada gana; si no hay, sigue al sistema operativo.
 */
function rw_theme_mode_head_script() {
	$js = <<<'JS'
(() => {
	let mode = null;
	try {
		mode = localStorage.getItem('rw-theme');
	} catch {
		// El almacenamiento puede estar bloqueado; se usa la preferencia del sistema.
	}
	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 );

La prioridad 0 lo pone cerca del inicio de wp_head. Va en línea a propósito: un archivo externo agregaría una petición bloqueante por unos cientos de bytes. El bloque try importa porque algunos navegadores lanzan un error cuando el almacenamiento está bloqueado. Y como la decisión ocurre en el navegador, la caché de página completa sigue funcionando; leer una cookie en el servidor obligaría a guardar una copia en caché por cada modo.

Un botón accesible

El botón es un shortcode, [rw_theme_toggle], colocado en el header. Imprime un <button> de verdad, con un nombre accesible fijo y aria-pressed para el estado:

<?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' );

Los lectores de pantalla lo anuncian más o menos como «Modo oscuro, botón de alternancia, no presionado», y «presionado» después de un clic. El nombre nunca cambia; solo cambia el estado. Cambiar las dos cosas (una etiqueta que pasa a «Modo claro» mientras aria-pressed también cambia) hace que el control se contradiga. El patrón de botón de WAI-ARIA explica los botones de alternancia en detalle.

Los íconos son decorativos, así que llevan aria-hidden, y el CSS muestra la luna en modo claro y el sol en modo oscuro. El botón mide 44×44px en escritorio y 40×40px en teléfonos, con un borde de al menos 3:1 para que se lea como un control. WCAG 2.1 AA no tiene una regla de tamaño de objetivo, pero los dos tamaños superan el mínimo de 24×24px del criterio 2.5.8 Tamaño del objetivo (mínimo) de WCAG 2.2.

El script del footer maneja los clics, mantiene aria-pressed sincronizado y sigue los cambios del sistema hasta que el visitante elige un modo:

<?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 {
				// No se guardó; la elección igual se aplica a esta página.
			}
			sync();
		});
	});

	// Sigue la preferencia del sistema hasta que el visitante elija.
	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 y widgets de terceros

El logo normal está hecho para fondos claros, así que el modo oscuro necesita una segunda versión. Los dos logos están en el header y el CSS muestra uno u otro: img[src*="-on-dark"] está oculto por defecto y aparece bajo html[data-theme="dark"], y el logo normal hace lo contrario. Un elemento <picture> con una media query de prefers-color-scheme se ve más limpio, pero solo sigue al sistema e ignora el botón. El logo oculto usa display: none, así que los lectores de pantalla no lo anuncian dos veces.

Los plugins de terceros son el otro hueco. Mi banner de cookies, CookieAdmin, pone estilos en línea desde JavaScript, y un estilo en línea le gana a cualquier selector. Por eso sus reglas para el modo oscuro necesitan !important, limitadas a las clases propias del banner.

Revisa el contraste de la paleta oscura

Una paleta oscura falla en lugares distintos que una clara: el texto secundario se oscurece de más, los bordes desaparecen y los colores de marca saturados vibran. Cada par se revisó contra AA (4.5:1 para texto, 3:1 para bordes y controles):

Par Colores Relación
Texto sobre el fondo #E6EDF7 sobre #0F172A 15.15:1
Texto secundario sobre fondo suave #A9B6CC sobre #131C31 8.28:1
Enlaces sobre el fondo #8AB4FF sobre #0F172A 8.55:1
Texto de botón sobre azul claro #0B1220 sobre #8AB4FF 8.96:1
Texto de éxito sobre el fondo #34D399 sobre #0F172A 9.29:1
Bordes de campos y del botón #62728E sobre #0F172A 3.67:1

El anillo de foco también pasa al azul claro, porque --rw-focus es uno de los tokens redefinidos.

Qué vigilar

  • Colores escritos a mano que vuelven a colarse por ajustes de widgets, HTML pegado o el CSS de un plugin nuevo. Vuelve a correr el grep después de cada cambio.
  • Estilos en línea que ponen los plugins. Solo !important los sobrescribe, así que limita esas reglas a las clases propias del plugin.
  • Imágenes con texto oscuro o fondo transparente, como logos y diagramas. Necesitan su propia versión para fondo oscuro.
  • Cuando un visitante elige un modo, el sitio deja de seguir al sistema. Si eso importa para tu público, agrega una opción «sistema» que borre el valor guardado.
  • Variables renombradas. El kit imprime las etiquetas como nombres de propiedad, así que renombrar una desconecta en silencio tus reglas de modo oscuro.
  • Probar solo un modo. Corre axe y tus revisiones de contraste en los dos, cada vez que cambie la paleta.

Este es el tipo de trabajo que hago para clientes: Desarrollo con Elementor