Selector de tema que no te falla: botón, desplegable y preferencia recordada
Alguien se quejará de tu selector de tema. Si solo ofreces modo claro, se quejarán a las 2 a. m. desde un teléfono en una habitación oscura. Si ofreces modo oscuro con un parpadeo de un fotograma en blanco, se quejarán por una migraña. Si guardas una preferencia que no persiste, se quejarán en cada carga de página, que es el tipo de queja que hace a los ingenieros cuestionarse su carrera.
Esta es una guía orientada a producción para construir una UI de cambio de tema usando HTML/CSS simples y un poco de JavaScript: un botón alternador, un desplegable con opción “System” y preferencia recordada. El objetivo no es “funciona en mi máquina.” El objetivo es “funciona en el mundo desordenado donde los navegadores bloquean almacenamiento, las páginas están cacheadas y alguien mide tu CLS.”
Requisitos que importan en producción
El cambio de tema parece pulido de UI hasta que lo despliegas a escala. Entonces se convierte en una característica de fiabilidad: afecta rendimiento, accesibilidad, caché y ocasionalmente revisiones de seguridad (todo lo que escribe en almacenamiento recibe miradas). Define requisitos desde el principio, porque “modo oscuro” no es un requisito; es un montón de casos límite con gabardina.
Requisitos básicos (no negociables)
- Lógica de tres estados:
light,darkysystem. Los usuarios esperan “seguir al sistema.” Las empresas también lo esperan, porque TI puede imponer políticas del sistema. - Persistencia: recordar la elección explícita del usuario entre recargas y sesiones. Si el almacenamiento está bloqueado, degradar con gracia.
- Sin parpadeo del tema incorrecto: evitar el “flash blanco” en la primera pintura cuando el usuario prefiere oscuro. No es estético; es daño de UX.
- Controles accesibles: un botón alternador con etiquetado correcto y un desplegable manejable por teclado. Si no es accesible, no está terminado.
- Baja complejidad: sin frameworks obligatorios. JS pequeño. CSS simple. Menos superficie de ataque implica menos errores.
- Valores predeterminados seguros: si algo falla (excepciones de almacenamiento, JS deshabilitado), la página sigue siendo legible.
Deseables que se pagan solos
- Múltiples temas: incluso si hoy solo lanzas claro/oscuro, estructura el CSS para que añadir “sepia” o “alto contraste” no requiera reescribir todo.
- Ganchos de telemetría: no necesitas analíticas intrusivas, pero sí una forma de saber si la persistencia de tema falla en un gran conjunto de usuarios.
- Compatibilidad con componentes: si tu app incorpora widgets de terceros, decide si heredan el tema o permanecen fijos.
Una cita para tener en la pantalla: “La esperanza no es una estrategia.” (idea parafraseada, atribuida al Gral. Gordon R. Sullivan) El cambio de tema necesita la misma actitud: define los modos de fallo y luego diseña para ellos.
Broma #1: Si tu selector de tema provoca un efecto flash a medianoche, felicidades: has inventado un nuevo tipo de alerta on-call.
Diseña el contrato: temas, orígenes y precedencia
La mayoría de los selectores de tema fallan porque nadie escribió el contrato. Quieres un árbol de decisión simple y explícito que puedas implementar una vez y luego dejar de pensar. La UI debe ser una vista de ese contrato, no el contrato en sí.
El modelo de tema
Define dos conceptos separados:
- Preferencia: lo que el usuario eligió:
system,light,dark,sepia… - Tema efectivo: lo que aplicas ahora: normalmente
light,darkosepia.
“System” es una preferencia, no un tema. Cuando la preferencia es system, calculas el tema efectivo desde prefers-color-scheme. Si mezclas esos conceptos, tu JS terminará reescribiendo la preferencia del usuario cuando el SO cambie—y los usuarios se enfadan porque no eligieron nada.
Orden de precedencia (no improvises)
- Preferencia explícita del usuario almacenada en el cliente (localStorage, cookie, perfil del servidor) gana.
- Preferencia del sistema vía
prefers-color-schemesi la preferencia del usuario essystemo no está establecida. - Valor por defecto (normalmente
light) si la detección falla o el JS está deshabilitado.
Estado útil para depuración
Me gusta almacenar dos atributos dataset en <html>:
data-theme=light/dark/sepia(efectivo)data-theme-source=explicit/system/fallback
Esto parece trivial hasta que depuras una queja de “el tema se restablece aleatoriamente” desde un navegador con privacidad estricta. La fuente te dice si el almacenamiento funcionó.
HTML UI: botón + desplegable sin deuda de accesibilidad
La UI tiene dos trabajos: (1) permitir cambiar, (2) comunicar el estado actual. Un solo botón está bien para dos temas, pero falla cuando añades “system” o “sepia.” Un desplegable es descubrible y escalable. Tener ambos suena redundante—hasta que intentas enviar algo que funcione para usuarios de teclado, usuarios avanzados y gente que solo quiere que la página deje de deslumbrarlos.
Lo que enviaremos
- Un botón alternador que cambia entre
lightydark. No fuerza “system.” Es una acción rápida. - Un desplegable de tema que ofrece
system,light,darkysepia.
Decisiones de accesibilidad
- Usa un verdadero
<button>y un<select>. Los controles nativos te dan mucha accesibilidad gratis. - Etiqueta los controles con
aria-labelo etiquetas visibles. Evita botones icono sin etiqueta a menos que disfrutes informes de auditoría enojados. - No sobreingenierices con roles ARIA para widgets simples. ARIA no es un kit de bricolaje; es una herramienta afilada.
El HTML en el encabezado de esta página es la implementación de referencia. Cópialo. Cambia los IDs si es necesario. Mantén la semántica.
Estrategia CSS: variables, color-scheme y valores sensatos
La forma más fácil de sobrevivir al theming es usar variables CSS para todos los tokens de color y establecerlas en :root y [data-theme="…"]. Si tu CSS está lleno de hex por todas partes, no tienes temas. Tienes un incidente futuro.
Usa tokens, no sensaciones
Empieza con un conjunto pequeño de tokens:
--bg,--fg--mutedpara texto secundario--cardpara superficies--border--link--focuspara los anillos de foco
Este es el conjunto mínimo viable que evita que tengas que retocar cada componente manualmente al cambiar temas.
color-scheme no es opcional
Los navegadores modernos usan color-scheme para decidir cómo pintar la UI incorporada (barras de desplazamiento, controles de formulario en algunos contextos) y para optimizar el renderizado. Si pones colores oscuros pero olvidas color-scheme: dark;, obtendrás “página oscura, inputs brillantes” u otras UIs extrañas.
En el CSS más arriba, cada tema establece color-scheme. Eso es intencional.
Prefiere selectores por atributo en <html>
Coloca data-theme en <html> (documentElement). Esto aplica el tema a todo el documento y funciona bien con contenido embebido. Evita poner clases de tema en <body> si tienes scripts que reemplazan el body o si haces renderizado del lado del servidor con parciales; crearás transiciones extrañas durante la hidratación.
Transiciones: úsalas con cuidado
A la gente le gustan los fundidos suaves. A los SREs les gusta el comportamiento predecible. Si añades transiciones globales como transition: background-color 250ms; en *, acabarás rompiendo algo (como cargadores esqueléticos o gráficos). Si añades transiciones, restrínjalas a unos pocos elementos y respeta prefers-reduced-motion:
- Usa
@media (prefers-reduced-motion: reduce)para desactivar transiciones. - No transiciones
color-scheme. No es ese tipo de fiesta.
JS pequeño que es realmente robusto (y evita el parpadeo)
Hay dos momentos de JavaScript que importan:
- Arranque temprano: elegir el tema efectivo antes de la primera pintura para evitar el parpadeo.
- Interacción: actualizar el tema tras la entrada del usuario y persistirlo.
Ubicación del script de arranque temprano
Pon un script mínimo en <head> que se ejecute antes de aplicar el CSS. Debe:
- Intentar leer la preferencia guardada desde
localStorage. - Si la preferencia guardada es
systemo falta, resolver desdeprefers-color-scheme. - Establecer
document.documentElement.dataset.themeinmediatamente. - Capturar excepciones de almacenamiento (sí, pasa) y degradar de forma segura.
El script en el head de este documento hace exactamente eso. Usa try/catch porque algunos navegadores lanzan al acceder al almacenamiento en ciertos modos de privacidad, y porque podrías embeber esta página en un contexto que niega el almacenamiento.
Script de interacción (la parte que la mayoría olvida endurecer)
A continuación está el resto del JS. Sincroniza el desplegable, hace que el botón alternador funcione, escucha cambios del tema del sistema cuando la preferencia es system, y persiste la preferencia.
cr0x@server:~$ cat theme-switcher.js
(function(){
var storageKey = "theme.preference";
var root = document.documentElement;
var btn = document.getElementById("theme-toggle");
var sel = document.getElementById("theme-select");
if (!btn || !sel) return;
function getSystemTheme() {
var mql = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
return (mql && mql.matches) ? "dark" : "light";
}
function getSavedPreference() {
try { return localStorage.getItem(storageKey); }
catch (e) { return null; }
}
function savePreference(pref) {
try { localStorage.setItem(storageKey, pref); }
catch (e) { /* ignore */ }
}
function applyTheme(pref) {
var effective = pref;
if (!effective || effective === "system") {
effective = getSystemTheme();
root.dataset.themeSource = "system";
} else {
root.dataset.themeSource = "explicit";
}
root.dataset.theme = effective;
sel.value = pref || "system";
btn.setAttribute("aria-label", "Toggle theme (currently " + effective + ")");
}
function toggleLightDark() {
var current = root.dataset.theme || "light";
var next = (current === "dark") ? "light" : "dark";
savePreference(next);
applyTheme(next);
}
// Initialize UI from saved preference (or system).
var pref = getSavedPreference() || "system";
applyTheme(pref);
btn.addEventListener("click", function(){
toggleLightDark();
});
sel.addEventListener("change", function(){
var pref = sel.value;
savePreference(pref);
applyTheme(pref);
});
// If user follows system, respond to system changes.
var mql = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
if (mql && mql.addEventListener) {
mql.addEventListener("change", function(){
var pref = getSavedPreference() || sel.value || "system";
if (pref === "system") applyTheme("system");
});
} else if (mql && mql.addListener) {
// Older Safari
mql.addListener(function(){
var pref = getSavedPreference() || sel.value || "system";
if (pref === "system") applyTheme("system");
});
}
})();
Notas que importan:
- Almacenamos la preferencia, no el tema efectivo. Así es como “system” sigue siendo significativo.
- Actualizamos la UI después de aplicar el tema. Esto evita una discordancia donde el desplegable diga “System” pero estés forzando dark.
- Escuchamos cambios del SO solo cuando la preferencia es system. De lo contrario sobrescribes la elección explícita del usuario, que es una forma rápida de recibir un ticket de alguien importante.
Broma #2: Un selector de tema sin persistencia es como una cafetera que olvida “fuerte” cada mañana—técnicamente funcional, emocionalmente inaceptable.
Hechos y contexto histórico (sí, es relevante)
El cambio de tema no es nuevo, pero la plataforma del navegador alrededor ha cambiado drásticamente. Algunos hechos útiles te ayudan a tomar mejores decisiones y evitar código de culto.
- Los primeros “temas” solían basarse en imágenes. En los 2000s, “skin” frecuentemente significaba intercambiar imágenes de fondo y sprites. Se veía bien, y cargaba como un camión lleno de ladrillos.
- Los modos oscuros a nivel de sistema hicieron que la preferencia del usuario sea portable. Cuando los SO introdujeron ajustes de apariencia a nivel de sistema, los usuarios dejaron de pensar “esta app tiene tema oscuro” y empezaron a esperar que todo siguiera su dispositivo.
prefers-color-schemecambió la expectativa por defecto. Una vez que los navegadores expusieron la preferencia del sistema mediante media queries, ignorarla pasó a ser un tema de accesibilidad y confort, no solo de estilo.- El “flash de contenido sin tema” precede al modo oscuro. FOUC era originalmente sobre CSS cargando tarde y mostrar HTML sin estilo. Los flashes de modo oscuro son la variante moderna.
color-schemees relativamente reciente y fácil de pasar por alto. Existe porque controles de formulario y scrollbars no siempre se manejan solo por CSS. Sin él, obtienes affordances inconsistentes.- LocalStorage es síncrono. Por eso es conveniente para arranque temprano pero peligroso si haces escrituras pesadas. Las lecturas suelen estar bien; las escrituras grandes en rutas críticas no.
- Algunos entornos lanzan al acceder al almacenamiento. Modos de navegación privada, webviews embebidos y configuraciones de privacidad estricta pueden hacer que
localStoragelance excepciones en vez de devolver null. - TI empresarial puede imponer políticas de apariencia. Una opción “system” alinea tu app con escritorios gestionados. Sin ella, peleas con políticas mediante CSS.
Tres micro-historias corporativas desde las trincheras del tema
Incidente: la suposición errónea de que “system” es un tema
Un panel interno de tamaño mediano lanzó un desplegable con tres opciones: Light, Dark y System. Bajo el capó, guardaban lo que decía el desplegable en localStorage y aplicaban ese valor como clase en <body>. El CSS tenía reglas para .light y .dark. Puedes ver el giro de la trama venir.
El primer día, todo parecía bien, porque la mayoría elegía Light o Dark. Pero la opción “System” fue popular entre usuarios de portátiles que se mueven entre oficina y casa. Cuando esos usuarios seleccionaban System, la app guardaba system y aplicaba la clase system. El CSS no la definía, así que la página volvió al estilo por defecto—principalmente modo claro, excepto por componentes parcialmente refactorizados que usaban variables. El resultado: tema mezclado. Fallos de contraste. Botones que parecían deshabilitados pero no lo estaban.
Entraron tickets de soporte como “UI aleatoria”. Los ingenieros inicialmente sospecharon de caché o despliegues parciales porque no se reproducía de forma consistente. Se reproducía consistentemente; solo requería que el usuario eligiera “System.” La suposición fue el bug: tratar “system” como un tema en vez de una preferencia que se resuelve a un tema efectivo.
La solución fue aburrida y rápida: almacenar la preferencia por separado, calcular el tema efectivo en tiempo de ejecución y establecer un único atributo en <html>. También añadieron data-theme-source para comprobaciones de sanidad. La próxima vez que alguien gritó “es aleatorio”, ya no lo era.
Optimización que salió mal: cachear el tema con demasiada agresividad
Un equipo de ecommerce quería eliminar el parpadeo del modo oscuro y lo redujo casi a cero renderizando el tema en servidor usando una cookie. Buena intención. Añadieron un proxy inverso y empezaron a cachear HTML agresivamente para usuarios no autenticados. Aún bien, si eres cuidadoso.
No fueron cuidadosos. Cachearon respuestas HTML sin variar por la cookie de tema. Eso significó que la primera petición tras un miss pobló la caché con HTML claro u oscuro, dependiendo de quien la golpeara primero. Todos los demás recibían esa variante cacheada. Los usuarios vieron su tema “cambiar aleatoriamente” entre visitas, porque la caché rotaba contenido según expiración, no preferencia.
Peor aún, el archivo CSS incluía variables específicas de tema generadas en el servidor. Así que el envenenamiento de caché no fue solo HTML; afectó el payload CSS también. El equipo culpó a los navegadores, luego a los CDNs, luego a la luna llena. Mientras tanto, los usuarios veían inconsistencia y asumían que el sitio era inestable.
El rollback fue doloroso porque el cambio de caché tocó presupuestos de rendimiento. La solución final: variar la caché por la cookie cuando esté presente, pero mantener un resolvedor robusto del lado cliente como respaldo. Y dejaron de generar CSS específico por petición; enviaron CSS estático con atributos de data. La “optimización” se convirtió en un impuesto de estabilidad hasta que cambiaron la arquitectura.
Aburrido pero correcto: un pequeño script en head que salvó el día
Un equipo financiero construyó una herramienta de informes internos usada en monitores grandes en oficinas luminosas y en portátiles en salas oscuras. Fueron estrictos con accesibilidad porque había auditores. Su UI se renderizaba en servidor, con algo de JS.
Al añadir modo oscuro, el primer prototipo estaba “bien” pero tenía el parpadeo típico al cargar. No aparecía tanto en dev porque las máquinas locales eran rápidas. En producción, con latencia real y un script de analytics inyectado, el parpadeo era muy visible.
En lugar de reescribir la app o traer bibliotecas de theming, hicieron algo poco épico: añadieron un script de head de 20 líneas que lee la preferencia y establece data-theme antes de que cargue el CSS. También escribieron una prueba de integración que carga la página con un valor de localStorage prefijado y aserta que la primera pintura ya está tematizada.
Eso fue todo. Sin heroicidades. Sin reescrituras de plataforma. Los auditores dejaron de encontrar regresiones de contraste porque los tokens estaban centralizados. El on-call dejó de recibir tickets “me lastiman los ojos”. Es un recordatorio de que la solución “aburrida” suele ser la fiable.
Tareas prácticas: comandos, salida esperada y decisiones
Puedes construir esto en un codepen y darlo por hecho. O puedes desplegarlo en un entorno real donde existen pasos de build, cachés, cabeceras y regresiones. Estas son tareas operativas que realmente haría (o pediría a alguien que haga) antes de llamarlo listo para producción.
Tarea 1: Verifica que tu HTML establece data-theme antes de la primera pintura (grep básico)
cr0x@server:~$ grep -n "document.documentElement.dataset.theme" -n index.html
42: document.documentElement.dataset.theme = theme;
48: document.documentElement.dataset.theme = "light";
Qué significa: Tienes un setter temprano. Si solo está en un bundle diferido, verás parpadeo.
Decisión: Si el setter no está en head o corre tarde, muévelo a un script inline en head.
Tarea 2: Confirma que el script de head corre antes del CSS externo (verificación de orden)
cr0x@server:~$ awk 'NR<=80{print NR ":" $0}' index.html
1:
2:
3:
4:
5:
6: Theme Switcher UI That Doesn’t Betray You: Button, Dropdown, and Remembered Preference
...
23:
113:
114:
Qué significa: En este ejemplo, el CSS está inline y el script de arranque está después, lo cual sigue estando bien porque el script se ejecuta inmediatamente durante el parseo, pero debes ser deliberado. Con CSS externo, quieres el script de arranque antes de que cargue el CSS o al menos antes del render.
Decisión: Si usas archivos CSS externos, coloca el script de arranque encima del <link rel="stylesheet"> o aplica CSS mínimo inline y establece el tema antes de que se aplique el CSS completo.
Tarea 3: Comprueba excepciones de almacenamiento en un entorno bloqueado
cr0x@server:~$ node -e 'console.log("Simulate: localStorage may throw in some browsers; ensure try/catch exists")'
Simulate: localStorage may throw in some browsers; ensure try/catch exists
Qué significa: Esta “tarea” es de proceso: debes programar como si el almacenamiento pudiera fallar. No puedes reproducir todos los modos de privacidad en CI.
Decisión: Mantén try/catch alrededor de lecturas/escrituras de almacenamiento. Trata la preferencia ausente como “system”.
Tarea 4: Valida que el bundle JS no sobrescriba accidentalmente el tema temprano
cr0x@server:~$ grep -R --line-number "dataset.theme =" dist/ | head
dist/app.js:812:root.dataset.theme = effective;
Qué significa: Tu JS principal también establece el tema, lo cual está bien si usa la misma lógica y clave de preferencia. No está bien si siempre por defecto usa light.
Decisión: Asegura que tanto el arranque temprano como el código de interacción compartan la misma fuente de preferencia y reglas de precedencia.
Tarea 5: Confirma que tu CSS no tiene colores hardcodeados que rompan los temas
cr0x@server:~$ grep -R --line-number -E "#[0-9a-fA-F]{3,6}\b|rgb\(|hsl\(" src/styles | head
src/styles/components/buttons.css:12: border: 1px solid var(--border);
src/styles/components/layout.css:4: background: var(--bg);
Qué significa: Idealmente el grep muestra principalmente uso de tokens. Si encuentra hex aleatorios, probablemente se verán mal en algún tema.
Decisión: Sustituye colores de componentes por variables. Deja algunos “acento de marca” si los has probado en ambos modos.
Tarea 6: Lint para transiciones globales accidentales
cr0x@server:~$ grep -R --line-number "transition:" src/styles | head
src/styles/base.css:88: transition: background-color 250ms ease, color 250ms ease;
Qué significa: Transiciones globales pueden introducir jank y animaciones inesperadas en gráficos o esqueletos.
Decisión: Si las transiciones aplican a grandes subárboles del DOM, acótalas a contenedores pequeños o quítalas.
Tarea 7: Verifica que el HTML servido no esté cacheado incorrectamente cuando usas cookies
cr0x@server:~$ curl -I -H "Cookie: theme=dark" http://localhost:8080/ | egrep -i "cache-control|vary|set-cookie"
Cache-Control: public, max-age=300
Vary: Accept-Encoding
Qué significa: Si sirves tema vía cookie y cacheas HTML, probablemente necesitas Vary: Cookie o desactivar caché para HTML personalizado. La salida arriba no varía por cookie, así que una caché puede mezclar temas.
Decisión: Evita tematizar en servidor para páginas cacheadas, o segmenta las cachés correctamente.
Tarea 8: Confirma que tu CSP permite el script inline en head (o planea un nonce)
cr0x@server:~$ curl -I http://localhost:8080/ | egrep -i "content-security-policy"
Content-Security-Policy: default-src 'self'; script-src 'self'
Qué significa: script-src 'self' bloquea scripts inline. Tu script de arranque inline no se ejecutará.
Decisión: Añade un nonce para el script inline, o carga un pequeño script externo de arranque con alta prioridad. Si no puedes, acepta algo de parpadeo.
Tarea 9: Valida el comportamiento de prefers-color-scheme en tests headless
cr0x@server:~$ node -e 'console.log("Headless browsers may default to light; set emulation if you rely on prefers-color-scheme in tests.")'
Headless browsers may default to light; set emulation if you rely on prefers-color-scheme in tests.
Qué significa: Tu CI puede no emular modo oscuro. Tests que asuman dark por defecto fallarán de forma impredecible.
Decisión: En E2E, establece la preferencia explícitamente (almacenamiento o emulación) y aserta data-theme.
Tarea 10: Mide si el parpadeo de tema es visible (sniff rápido de rendimiento)
cr0x@server:~$ google-chrome --headless --disable-gpu --dump-dom http://localhost:8080/ | head
<!doctype html><html lang="en" data-theme="light" data-theme-source="system">...
Qué significa: El DOM volcado muestra que el atributo de tema está establecido temprano. Esto no prueba completamente la ausencia de parpadeo (el timing de pintura es complicado en headless), pero es un chequeo de sanidad útil.
Decisión: Si data-theme no aparece en el DOM volcado, tu arranque temprano no se ejecutó, probablemente por CSP u orden.
Tarea 11: Confirma que los controles UI coinciden con la preferencia almacenada
cr0x@server:~$ node -e 'console.log("Manual check: select value should show system/light/dark/sepia, not always default. Verify after reload.")'
Manual check: select value should show system/light/dark/sepia, not always default. Verify after reload.
Qué significa: Si el desplegable siempre se reinicia visualmente, los usuarios pensarán que la configuración no se guardó incluso si sí lo hizo.
Decisión: Asegura que estableces select.value desde la preferencia guardada, no desde el tema efectivo.
Tarea 12: Verifica que el atributo de tema no sea eliminado por sanitizadores HTML
cr0x@server:~$ curl -s http://localhost:8080/ | head -n 3
Qué significa: Algunos motores de plantillas o sanitizadores eliminan atributos desconocidos. Si data-theme desaparece, el CSS no se aplicará como esperas.
Decisión: Si los atributos se eliminan, aplica el tema vía clase o configura el sanitizador/templating para permitir data-*.
Tarea 13: Revisa que widgets de terceros no hardcodeen colores
cr0x@server:~$ grep -R --line-number "style=" public/widgets | head
public/widgets/legacy-chat.html:17:<div style="background:#fff;color:#000">Chat</div>
Qué significa: Los estilos inline ignorarán tu sistema de tokens. En modo oscuro obtendrás cajas brillantes embebidas en una página oscura.
Decisión: Refactoriza el estilo del widget para usar variables, o enmárcalo visualmente (forzarlo a “siempre claro”) para que parezca intencional.
Tarea 14: Confirma que el servidor no comprime/altere scripts inline de forma que los rompa
cr0x@server:~$ curl -s -D - http://localhost:8080/ -o /dev/null | egrep -i "content-encoding|content-type"
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Qué significa: La compresión está bien. Lo que buscas es reescritura inesperada (algunos “optimizadores” reescriben scripts inline).
Decisión: Si usas middleware que reescribe HTML, excluye el script de arranque head de transformaciones o muévelo a un archivo estático.
Guía rápida de diagnóstico
Cuando el cambio de tema falla, la gente describe síntomas como “aleatorio”, “parpadea” o “ignora mi ajuste”. No persigas sensaciones. Ejecuta una secuencia de triage estricta que te lleve rápidamente a la causa raíz.
Primero: determina si el problema es persistencia, resolución o tiempo de pintura
- Chequeo de persistencia: Tras poner Dark, recarga. ¿Permanece dark? Si no, no se lee o escribe almacenamiento.
- Chequeo de resolución: Si la preferencia es “System”, ¿cambia el tema al cambiar el SO? Si no, falta o está roto el listener de media query.
- Chequeo de tiempo de pintura: ¿El tema correcto se aplica finalmente, pero ves un parpadeo primero? Entonces el script de arranque llega tarde o está bloqueado por CSP.
Segundo: inspecciona la fuente de la verdad en el DOM
- Mira
<html data-theme="..." data-theme-source="...">. Sidata-theme-sourceesfallback, el almacenamiento probablemente lanzó. - Confirma que el valor del desplegable refleja la preferencia, no el tema efectivo.
Tercero: verifica interferencias de caché y políticas
- Si usas cookies para tema en el servidor, revisa cabeceras de caché y
Vary. - Revisa CSP. Scripts inline de arranque mueren con frecuencia aquí.
- Revisa si una política corporativa del navegador deshabilita el almacenamiento persistente para el sitio.
La pregunta clave
El cuello de botella usual no es CPU. Es el orden: el navegador pinta antes de que tu decisión de tema se ejecute. Arregla eso primero. Luego preocúpate por la elegancia.
Errores comunes: síntoma → causa raíz → solución
1) Síntoma: “El modo oscuro parpadea en blanco al recargar”
Causa raíz: El tema se aplica después de la primera pintura (bundle JS diferido, o se ejecuta después de cargar el CSS).
Solución: Incrusta un script mínimo en head para establecer data-theme antes del render; asegúrate de que CSP lo permita o usa un nonce/archivo de arranque estático.
2) Síntoma: “Selecciono System y la UI está medio tematizada”
Causa raíz: Tratar system como una clase de tema, en lugar de resolverla a un tema efectivo.
Solución: Almacena la preferencia (system) pero aplica el tema efectivo (light/dark) en data-theme. No crees .system en CSS a menos que realmente lo quieras.
3) Síntoma: “La configuración no persiste, solo funciona hasta que cierro la pestaña”
Causa raíz: Usar sessionStorage sin querer, o escrituras de almacenamiento fallando por excepciones.
Solución: Usa localStorage con try/catch, o recurre a una cookie. Mantén la app usable si la persistencia falla.
4) Síntoma: “El desplegable dice System pero la página está forzada a Dark”
Causa raíz: El estado UI se deriva del tema efectivo en lugar de la preferencia almacenada.
Solución: Siempre establece el valor del desplegable desde la preferencia; calcula el tema efectivo por separado.
5) Síntoma: “El tema cambia solo cuando el SO cambia, aunque yo elegí Dark”
Causa raíz: Escuchar cambios de prefers-color-scheme incondicionalmente y reaplicar comportamiento “system”.
Solución: Responde a cambios del sistema solo cuando la preferencia es system.
6) Síntoma: “Inputs y scrollbars permanecen claros en tema oscuro”
Causa raíz: Falta la declaración color-scheme para el tema oscuro.
Solución: Establece color-scheme: dark; dentro del selector de tu tema oscuro.
7) Síntoma: “Usuarios reportan tema aleatorio, pero solo en producción”
Causa raíz: Caché mezclando contenido entre variantes de tema (theming por cookie + caché compartida), o CSP bloqueando el script de arranque temprano.
Solución: Arregla la caché (vary/disable para HTML personalizado) y confirma que CSP soporte el bootstrap de tema.
8) Síntoma: “El alternador de tema funciona, pero la página se vuelve lenta”
Causa raíz: Actualizaciones masivas del DOM debido a transiciones o recálculos; a veces por aplicar tema a muchos nodos individualmente.
Solución: Aplica el tema en la raíz (<html>) únicamente. Evita transiciones globales. Mantén los tokens de tema pequeños.
Listas de verificación / plan paso a paso
Paso a paso: desplegar un selector de tema fiable
- Define preferencias: Decide los valores permitidos (
system,light,dark, extras opcionales). - Define tokens: Elige un conjunto pequeño de variables CSS que todos los componentes usen.
- Implementa selectores de tema:
:rootpara defecto,[data-theme="dark"]etc. Manténlo en el nivel de<html>. - Añade
color-scheme: El tema claro establecelight, el oscuro establecedark. - Escribe el script de arranque en head: Lee preferencia (try/catch), resuelve preferencia del sistema, establece
data-theme. - Añade controles UI: Botón y select nativos, etiquetados, manejables por teclado.
- Escribe el script de interacción: Cambia preferencia, persiste, aplica tema efectivo, sincroniza la UI.
- Maneja cambios del sistema: Escucha
prefers-color-schemesolo cuando la preferencia essystem. - Prueba con almacenamiento bloqueado: Confirma que la página sigue siendo legible y no lanza errores que rompan otros scripts.
- Prueba con CSP: Asegura que tu script inline de arranque está permitido (nonce) o muévelo a un archivo estático.
- Prueba el comportamiento de caché: Si tematizas en servidor, confirma segmentación de caché por preferencia.
- Despliega y monitoriza: Añade logging ligero para fallos de persistencia de tema si tu entorno lo permite (contar excepciones sin capturar datos de usuario).
Checklist pre-despliegue (lo que haría antes de habilitar por defecto)
- La página carga con el tema correcto cuando la preferencia está establecida (sin parpadeo visible en navegador real, no solo headless).
- El desplegable y el estado del botón siempre reflejan la preferencia/tema efectivo real.
- Con JS deshabilitado, la página sigue siendo legible y los controles no inducen a error.
- Con almacenamiento bloqueado, la selección de tema funciona para la sesión (aunque no persista) y no rompe la app.
- Comprobaciones de contraste pasan para texto principal, texto muted, botones y anillos de foco en todos los temas.
- No hay transiciones globales que causen animaciones raras.
- CSP es compatible con el enfoque de arranque.
Preguntas frecuentes (FAQ)
1) ¿Debo almacenar el tema efectivo o la preferencia?
Almacena la preferencia (system/light/dark). Calcula el tema efectivo en tiempo de ejecución. De lo contrario “System” pierde sentido y acabarás sobrescribiendo usuarios inesperadamente.
2) ¿Por qué no solo confiar en prefers-color-scheme y omitir la UI?
Porque los usuarios quieren control. Además, escritorios empresariales a menudo tienen ajustes del sistema que no coinciden con la comodidad personal en apps específicas. Dales una anulación.
3) ¿LocalStorage es seguro para esto?
Está bien para una cadena pequeña. El problema real es que puede lanzar o no estar disponible en algunos modos de privacidad. Envuelve el acceso en try/catch y degrada con gracia.
4) ¿Por qué poner el script de arranque inline en el head?
Para evitar el parpadeo de tema equivocado. Los scripts externos cargan más tarde y pueden bloquearse o retrasarse. El código inline en head corre durante el parseo y puede establecer data-theme antes de pintar.
5) Mi CSP bloquea scripts inline. ¿Qué hago?
Usa un nonce para el script inline, o sirve un pequeño script externo de “arranque de tema” con alta prioridad. Si no puedes, acepta algún parpadeo y hazlo menos doloroso con valores sensatos.
6) ¿El botón alternador debe ciclar System → Light → Dark?
No. Mantén el botón como un cambio rápido Light/Dark. Pon System (y temas extras) en el desplegable. Los usuarios entienden esta separación rápidamente y evita estados extraños.
7) ¿Necesito escuchar cambios del tema del sistema?
Sólo si la preferencia del usuario es system. De lo contrario es invasivo. También recuerda que Safari antiguo usa addListener en lugar de addEventListener para media queries.
8) ¿Dónde debería adjuntar el atributo de tema, <html> o <body>?
<html>. Es la raíz para todo el documento y reduce rarezas durante la hidratación o reemplazo del body. También se alinea con el comportamiento de color-scheme.
9) ¿Cómo evito reescribir montones de CSS?
Usa variables CSS como tokens y haz que los componentes dependan solo de tokens. Entonces las definiciones de tema son solo conjuntos de tokens. Este es el único enfoque que se mantiene manejable después del primer sprint.
10) ¿Puedo añadir un tema “alto contraste”?
Sí, y deberías considerarlo si tu app se usa en sesiones largas. Trátalo como cualquier otro tema: define tokens, añade una opción y prueba contraste e indicadores de foco cuidadosamente.
Conclusión: siguientes pasos que sí puedes hacer
Un selector de tema es una característica pequeña con un radio de impacto sorprendentemente grande. Hecho bien, desaparece—los usuarios obtienen confort, mejora la accesibilidad y tu UI deja de pelear con el SO. Hecho mal, se convierte en un generador de incidentes menores: tickets de parpadeo, reportes de “comportamiento aleatorio” y una lenta pérdida de confianza.
Siguientes pasos:
- Implementa la estructura CSS basada en tokens (
:root+[data-theme]) y establececolor-schemepor tema. - Añade el script de arranque en head y verifica que se ejecute bajo tu CSP y configuración de caché.
- Conecta el desplegable para almacenar la preferencia, no el tema efectivo, y mantén el alternador como un flip rápido light/dark.
- Ejecuta la guía rápida de diagnóstico una vez—a propósito—para que conozcas los modos de fallo antes de que los usuarios los encuentren.