Modo oscuro que no apesta: prefers-color-scheme + patrón de alternancia manual

¿Te fue útil?

El modo oscuro es fácil hasta que deja de serlo. La primera vez que tu app parpadea en blanco a las 2 a.m. en un teléfono OLED, lo sentirás en el alma. La segunda vez es peor: alguien abre un bug porque tu “modo oscuro” deja los gráficos ilegibles, se restablece en cada recarga y rompe la impresión. Si entregas sistemas en producción, los errores de tema no son cosméticos. Son errores de confianza.

El objetivo aquí es un patrón que se comporte como un servicio bien gestionado: respeta la plataforma, da al usuario una anulación clara, persiste de forma predecible, evita el parpadeo y sigue siendo testeable. Implementarás prefers-color-scheme correctamente, añadirás un interruptor manual y mantendrás todo mantenible cuando tu sistema de diseño gane complejidad.

Cómo debería verse “bien” en producción

Un sistema de temas no es un desfile de moda. Es un contrato entre el navegador, el SO, tu CSS, tu JavaScript y la intención del usuario.

No negociables

  • Por defecto: seguir prefers-color-scheme.
  • Anulación: un interruptor manual que tenga prioridad sobre la preferencia del sistema.
  • Persistencia: recordar la anulación entre sesiones, sin quedarse atascado en el pasado.
  • Sin destellos: la primera pintura debe mostrar ya el tema previsto.
  • Accesible: contraste correcto, anillos de foco visibles y un interruptor accesible por teclado/lector de pantalla.
  • Componible: funciona dentro de un sistema de diseño y en micro-frontends sin tres “verdades” en conflicto.

El modo de fallo más común es tratar el “modo oscuro” como un añadido CSS secundario. Se convierte en un montón de overrides, más un pequeño script que cambia una clase después de la carga. Eso no es una característica; es una condición de carrera con la interfaz de usuario.

Una cita para mantenerte honesto. La esperanza no es una estrategia. — Gene Kranz. (Sí, está rehecha hasta la saciedad. También sigue siendo correcta.)

Broma corta #1: Si tu interruptor de tema necesita un spinner, no construiste un interruptor: construiste un sistema distribuido.

Hechos e historia que conviene conocer

El modo oscuro no llegó como una sola característica; fue acumulándose por restricciones de hardware, necesidades de accesibilidad y presupuestos de energía. Algunos puntos concretos que influyen en las decisiones de implementación actuales:

  1. Los primeros “UI oscuras” trataban del fósforo y el deslumbramiento: las UIs de terminal y los primeros monitores favorecían texto claro sobre fondos oscuros en parte para reducir el deslumbramiento percibido en entornos con poca luz.
  2. OLED cambió la ecuación: en OLED, los píxeles negros pueden consumir mucho menos que los blancos. En LCD, la luz de fondo domina, por lo que el ahorro puede ser insignificante.
  3. prefers-color-scheme es una primitiva web relativamente reciente: se hizo ampliamente utilizable solo después de que los navegadores modernos alinearan el soporte de media queries; antes de eso, cada sitio inventaba su propio toggle y lógica de persistencia.
  4. Los sistemas de diseño complicaron el problema: una vez que tienes tokens, componentes y múltiples productos, el enfoque de “simplemente sobreescribir unos pocos colores” deja de escalar.
  5. Los modos de alto contraste existen desde antes del hype del modo oscuro: las configuraciones de colores forzados a nivel de SO ya eran una realidad; muchos despliegues de “modo oscuro” los rompieron accidentalmente.
  6. La impresión es un stakeholder oculto: un fondo oscuro puede estar bien en pantalla, desastroso en papel o en PDF a menos que manejes explícitamente los estilos de impresión.
  7. Los gráficos y la visualización de datos son víctimas frecuentes: los degradados de color, las líneas de cuadrícula y el contraste de anotaciones requieren ajuste específico; no puedes simplemente invertir la página.
  8. A las apps empresariales les encantan los iframes: contextos embebidos (webviews, iframes) complican la propagación del tema y la persistencia.

El contrato central: preferencia del sistema + anulación del usuario

Este es el modelo que funciona: trata el tema como una preferencia de tres estados, no como un booleano.

  • Sistema (valor por defecto): seguir prefers-color-scheme.
  • Light (anulación): el usuario fuerza el tema claro.
  • Dark (anulación): el usuario fuerza el tema oscuro.

Si almacenas solo “dark=true/false” inevitablemente romperás a alguien: el usuario que alternó una vez hace meses, luego cambió la preferencia del SO y ahora se pregunta por qué tu app está desincronizada. El estado de tres valores soluciona eso. Almacena theme=system|light|dark y calcula el tema efectivo en tiempo de ejecución.

Dónde almacenarlo

Tienes dos ubicaciones de persistencia comunes:

  • localStorage: lo más simple, del lado del cliente, por perfil de navegador. Adecuado para la mayoría de sitios.
  • Cookie: necesario cuando quieres que SSR devuelva el tema correcto en la primera respuesta sin esperar a JS.

Elige una. No escribas en ambas a menos que disfrutes depurar sutiles bugs de precedencia a las 3 a.m.

Cómo aplicarlo

Usa un único atributo autoritativo en <html> (o <body>) como data-theme="dark". Evita dispersar clases de tema por las raíces de los componentes. Cada punto de conmutación extra es un posible split-brain.

Arquitectura CSS que no colapsará después

No temas reescribiendo estilos de componentes uno por uno. Morirás de pequeñas molestias. Tematiza mediante tokens: variables CSS que representen intención (superficie, texto, borde, acento), no colores en bruto.

Usa tokens semánticos, no tokens “blue-500”

Cuando un product manager pide “un fondo un poco más cálido en modo oscuro”, quieres cambiar una variable, no buscar veinte valores hex. Un conjunto de tokens práctico se ve así:

  • --color-bg, --color-surface
  • --color-text, --color-muted
  • --color-border
  • --color-link, --color-link-visited
  • --color-focus
  • --shadow-elevation-1 (sí, también tokens de sombras)

Patrón CSS base

Establece valores por defecto en :root y sobrescribe por tema usando un selector de atributo. Luego opcionalmente aplica la preferencia del sistema cuando el usuario esté en modo system.

cr0x@server:~$ cat theme.css
:root {
  color-scheme: light dark;
  --color-bg: #ffffff;
  --color-surface: #f6f7f9;
  --color-text: #111827;
  --color-muted: #4b5563;
  --color-border: #d1d5db;
  --color-focus: #2563eb;
}

:root[data-theme="dark"] {
  --color-bg: #0b1220;
  --color-surface: #0f172a;
  --color-text: #e5e7eb;
  --color-muted: #94a3b8;
  --color-border: #243041;
  --color-focus: #60a5fa;
}

@media (prefers-color-scheme: dark) {
  :root[data-theme="system"] {
    --color-bg: #0b1220;
    --color-surface: #0f172a;
    --color-text: #e5e7eb;
    --color-muted: #94a3b8;
    --color-border: #243041;
    --color-focus: #60a5fa;
  }
}

html, body {
  background: var(--color-bg);
  color: var(--color-text);
}

Qué te ofrece esto: un único lugar para definir valores de tema y un mecanismo de selección limpio y testeable. También, fija atención en color-scheme: light dark;. Eso indica al navegador que soportas ambos, para que controles de formulario y barras de desplazamiento puedan renderizar apropiadamente en muchos entornos.

No temas invirtiendo

Los filtros CSS como filter: invert(1) son un truco de fiesta. Rompen imágenes, destruyen colores de marca y hacen que las capturas de pantalla parezcan evidencia en una investigación paranormal.

Maneja imágenes e iconos explícitamente

Para iconos, prefiere SVG con fill="currentColor" para que hereden --color-text o un color definido por token. Para imágenes rasterizadas, decide: ¿se mantienen igual o tienen una variante oscura? Si son imágenes críticas del producto (mapas, diagramas), probablemente necesites variantes.

El interruptor: máquina de estados, no intuición

Tu JavaScript tiene tres tareas:

  1. Determinar la preferencia almacenada (system, light, dark).
  2. Establecer data-theme antes de la primera pintura cuando sea posible.
  3. Exponer una UI de interruptor que cambie la preferencia almacenada y actualice el DOM.

Una implementación mínima y fiable

Mantén la lógica pequeña. Hazla aburrida. La creatividad pertenece a tu producto, no a la plomería del tema.

cr0x@server:~$ cat theme.js
(function () {
  const STORAGE_KEY = "theme-preference"; // "system" | "light" | "dark"
  const root = document.documentElement;

  function readPreference() {
    try {
      const v = localStorage.getItem(STORAGE_KEY);
      if (v === "light" || v === "dark" || v === "system") return v;
    } catch (e) {}
    return "system";
  }

  function writePreference(value) {
    try {
      localStorage.setItem(STORAGE_KEY, value);
    } catch (e) {}
  }

  function applyPreference(value) {
    root.setAttribute("data-theme", value);
  }

  function cyclePreference(current) {
    // Opinionated: cycle system -> light -> dark -> system
    if (current === "system") return "light";
    if (current === "light") return "dark";
    return "system";
  }

  // Early apply on load
  const initial = readPreference();
  applyPreference(initial);

  // Export small API for the button
  window.theme = {
    get: readPreference,
    set: (v) => { writePreference(v); applyPreference(v); },
    cycle: () => {
      const next = cyclePreference(readPreference());
      writePreference(next);
      applyPreference(next);
      return next;
    }
  };
})();

Esto es intencionalmente poco emocionante. Es un cumplido. El botón de alternancia puede llamar a window.theme.cycle() y actualizar su etiqueta.

Escuchar cambios del sistema (pero solo en modo system)

Si el usuario seleccionó system, lo dice en serio. Si seleccionó dark, lo dice en serio. Así que solo responde a cambios del tema del SO cuando la preferencia almacenada es system.

cr0x@server:~$ cat theme-system-listener.js
(function () {
  const media = window.matchMedia("(prefers-color-scheme: dark)");
  function onChange() {
    const pref = window.theme && window.theme.get ? window.theme.get() : "system";
    if (pref === "system") {
      document.documentElement.setAttribute("data-theme", "system");
    }
  }
  if (media.addEventListener) media.addEventListener("change", onChange);
  else if (media.addListener) media.addListener(onChange);
})();

Fíjate en lo que no hace: no reescribe localStorage. Los cambios de preferencia del sistema no deben sobrescribir la intención del usuario. Tu app debe simplemente re-evaluar los colores efectivos vía la media query en CSS.

Eliminar el destello (FOUC/FOWT) sin hacks

El destello de tema incorrecto suele ser autoinfligido: cargas CSS que dependen de un atributo data-theme, pero solo estableces ese atributo tras renderizar la página. Los usuarios ven el tema por defecto durante una fracción de segundo. En un escritorio rápido es un parpadeo. En un teléfono de gama media con caché fría, es una bengala.

Mejor práctica: incluir un pequeño script “bootstrap” de tema en línea en el head

Sí, en línea. Sí, antes de tu CSS si puedes. Esto no es “bloat JS”; es corrección. Mantenlo mínimo y síncrono.

cr0x@server:~$ cat theme-bootstrap-inline.js
(function () {
  try {
    var v = localStorage.getItem("theme-preference");
    if (v !== "light" && v !== "dark" && v !== "system") v = "system";
    document.documentElement.setAttribute("data-theme", v);
  } catch (e) {
    document.documentElement.setAttribute("data-theme", "system");
  }
})();

Luego tu CSS media query para system toma el relevo. El navegador puede computar estilos antes de la primera pintura.

¿Y qué pasa con CSP?

Si tu CSP no permite scripts en línea, tienes un trade-off: aceptar parpadeo, permitir un pequeño script en línea mediante nonce/hash, o hacer selección de tema en el servidor via cookies. En entornos corporativos, CSP suele ganar; plánificalo temprano en vez de descubrirlo tras la revisión de seguridad.

Una cosa más: establece color-scheme

Aunque clavas el fondo y el texto, los controles nativos pueden quedarse atrás. Declarar color-scheme: light dark; en :root ayuda a que los navegadores rendericen controles de formulario en un estilo coincidente. No es perfecto en todas partes, pero es barato y normalmente correcto.

SSR, hidratación y por qué importa la primera pintura

Las apps solo-cliente pueden permitirse algo de indecisión. Las apps con SSR no. Con SSR, los usuarios ven HTML y CSS antes de que tu bundle cargue. Si tu servidor envía HTML en tema claro pero el cliente decide oscuro tras la hidratación, obtienes un flip visible y a veces cambios de layout (tipos, bordes, imágenes). Parece que la página se reinicia.

Opciones de decisión en el servidor

  • Anulación por cookie: Si un usuario tiene theme=dark, el servidor puede renderizar oscuro inmediatamente.
  • Preferencia del sistema: El servidor no puede saber confiablemente prefers-color-scheme solo desde la petición HTTP. Existen algunos client hints más nuevos, pero trátalos como opcionales, no obligatorios.
  • Híbrido: el render por defecto del servidor usa data-theme="system". Si la cookie indica anulación, establece data-theme en consecuencia.

El enfoque híbrido funciona bien: deja que CSS y las media queries manejen la preferencia del sistema; deja que las cookies manejen anulaciones explícitas. Si haces esto, mantén el alcance de la cookie consistente entre subdominios que comparten la misma UI; nada dice “suite de producto cohesiva” como que cada subdominio redecida el tema por tu cara.

Hidratación desajustada: la clásica trampa

Si tu framework del lado del cliente renderiza markup específico de tema (por ejemplo, SVGs diferentes, árboles de componentes distintos), y el servidor renderizó el otro tema, puedes disparar warnings de hidratación o un re-render completo. La solución: mantener el marcado idéntico entre temas siempre que sea posible y variar la presentación mediante variables CSS.

Accesibilidad: contraste, foco y reducción de movimiento

El modo oscuro puede ser más cómodo para algunos ojos, peor para otros. No existe una “comodidad” universal. Tu trabajo es evitar que la UI sea ilegible o físicamente desagradable.

El contraste no es opcional

En temas oscuros, los diseñadores a menudo eligen texto gris apagado sobre fondos casi negros. Se ve “premium”. También falla el contraste y convierte la lectura en trabajo. Si necesitas un tono tenue, úsalo con moderación (etiquetas secundarias), no para contenido primario.

Los anillos de foco deben sobrevivir al tema

Muchos equipos eliminan los outlines por estética y luego olvidan volver a añadirlos. Un tema oscuro sin foco visible es funcionalmente roto para usuarios de teclado. Usa un token como --color-focus y mantenlo lo bastante brillante para ambos temas.

Honra prefers-reduced-motion

Las transiciones de tema (desvanecer entre temas) pueden verse elegantes. También pueden desencadenar sensibilidad al movimiento si se abusa. Respeta prefers-reduced-motion y desactiva o acorta las transiciones.

cr0x@server:~$ cat motion.css
@media (prefers-reduced-motion: reduce) {
  * {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
  }
}

Broma corta #2: Si animas un cambio de tema durante 600ms, tus usuarios tendrán tiempo para prepararse un té y reconsiderar tu producto.

Observabilidad: medir adopción y fallos

El modo oscuro es una característica de producto. Eso significa que deberías medirlo. No obsesivamente. Lo suficiente para detectar regresiones y entender si el interruptor hace lo que crees.

Qué registrar (y qué no)

  • Registrar: transiciones de estado de preferencia de tema (system → dark, dark → system).
  • Registrar: tema efectivo en la primera pintura (si puedes instrumentarlo), para detectar incidentes de parpadeo.
  • No registrar: “el usuario tiene preferencia oscura del SO” como atributo ligado a identidad sin considerar privacidad y políticas. Sorprendentemente esto ayuda a fingerprinting cuando se combina con otras señales.

Un enfoque práctico: emitir un evento analítico al interactuar con el interruptor. Por separado, registrar métricas cliente para “coincidencia de primera pintura de tema” muestreada a baja tasa. Si ves picos de tasa de desajuste tras un despliegue, sabes exactamente dónde mirar: script bootstrap, plantilla SSR o orden de CSS.

Tres micro-historias corporativas desde las trincheras de temas

1) El incidente causado por una suposición incorrecta

Una compañía lanzó un nuevo componente de cabecera como parte de una actualización del sistema de diseño. Se veía genial en modo claro. En modo oscuro se veía mayormente bien también—hasta que abrías un dropdown. El fondo del menú era oscuro, pero el texto del menú seguía siendo gris oscuro. No era solo bajo contraste; era invisible.

La suposición incorrecta fue sutil: el equipo creía que “modo oscuro” era solo intercambiar colores de fondo y texto a nivel de página. El componente del dropdown, sin embargo, tenía colores codificados en su hoja de estilos anidada que nunca usaba tokens. La librería de componentes “soportaba theming” en el README, pero solo a nivel del shell.

Primero llegaron los tickets de soporte. Luego usuarios internos empezaron a pegar capturas en el chat con anotaciones tipo “¿es esto una bandera de característica para ceguera?”. Se declaró un incidente porque el dropdown controlaba una configuración de seguridad de cuenta. La gente no podía cerrar sesión ni gestionar sesiones en modo oscuro, y la app por defecto estaba en oscuro para un gran segmento de usuarios.

La solución no fue heroica. Introdujeron tokens semánticos y exigieron que cada estilo de componente los usara, con reglas de lint para rechazar colores en bruto excepto en las definiciones de tokens. Luego añadieron tests de regresión visual para flujos críticos en ambos temas. La lección: las suposiciones sobre “theming global” se desploman en cuanto un componente sale con colores codificados.

2) La optimización que salió mal

Otro equipo quiso eliminar el pequeño script inline de bootstrap de tema porque su presupuesto de performance era ajustado y su equipo de seguridad se quejaba del JS en línea. Así que movieron la inicialización del tema al bundle principal y usaron defer. También añadieron una bonita transición de fade entre temas para que “se sintiera premium”.

En laboratorio parecía bien. En dispositivos reales fue un caos. Usuarios con redes lentas vieron un flash blanco, luego un fade a oscuro, luego un segundo flash cuando la hidratación reemplazó el markup renderizado en servidor. La transición amplificó el problema: en vez de un breve parpadeo, se convirtió en una animación perceptible que llamaba la atención.

Empeoró en webviews embebidos dentro de una app nativa. El webview a veces retrasaba el bundle JS más de lo esperado, así que el tema inicial persistía durante segundos. La gente pensó que el interruptor “no funcionaba”, porque sí funcionaba—eventualmente. Simplemente no en un plazo que los humanos consideren razonable.

Revirtieron la transición, reintrodujeron un script inline con hash CSP y lanzaron una anulación por cookie para SSR. La performance mejoró en la práctica porque los usuarios dejaron de provocar re-renders y cambios de layout causados por flips de tema. La lección: optimizar por la métrica equivocada (pureza del bundle) puede empeorar la performance visible al usuario.

3) La práctica aburrida pero correcta que salvó el día

Una tercera organización hizo algo que parecía dolorosamente poco glamouroso: crearon un documento “contrato de tema” y una pequeña suite de pruebas de conformidad para componentes. Cada componente tenía que renderizar correctamente bajo data-theme="light", "dark" y "system" con ambas configuraciones de prefers-color-scheme en el runner de pruebas.

También estandarizaron el nombre de tokens y prohibieron variables ad-hoc en CSS de componentes. ¿Quieres un nuevo matiz? Añade un token, justifícalo y conéctalo en ambos temas. Al principio ralentizó los primeros PRs. La gente se quejó. Luego las quejas cesaron porque las reglas eran predecibles.

Un trimestre después llegó un gran rebrand: nuevo color de acento, nuevo tinte de fondo, sombras actualizadas. Los equipos esperaban roturas. En su lugar, cambiaron valores de tokens, ejecutaron tests y desplegaron. La disciplina “aburrida” significó que no tuvieron que rastrear cientos de archivos CSS buscando hex como arqueólogos excavando una mala decisión.

La lección: el mejor sistema de tema es mayormente proceso. El código es la parte fácil.

Tareas prácticas (con comandos) para depurar y decidir

Estas son tareas que puedes ejecutar hoy en una máquina de desarrollo o en CI. Cada una incluye: un comando, qué significa la salida y qué decisión tomar. El objetivo es operativo: reducir el misterio, reducir las conjeturas.

Tarea 1: Verificar que tu CSS compilado realmente contiene selectores de tema

cr0x@server:~$ rg -n 'data-theme="dark"|prefers-color-scheme' dist/assets/*.css
dist/assets/app.9c31.css:12::root[data-theme="dark"]{--color-bg:#0b1220;...}
dist/assets/app.9c31.css:38:@media (prefers-color-scheme: dark){:root[data-theme="system"]{...}}

Significado: Puedes ver tanto el override explícito como la media query en modo sistema en el artefacto desplegado.

Decisión: Si estas cadenas no están presentes, tu pipeline de build eliminó o nunca incluyó CSS de tema. Arregla el orden de imports o la configuración del bundler antes de depurar el comportamiento de la UI.

Tarea 2: Detectar colores hex codificados en CSS de componentes (bypass de tokens)

cr0x@server:~$ rg -n --glob='**/*.css' '#[0-9a-fA-F]{3,8}\b' src/
src/components/dropdown.css:44:color: #111827;
src/components/dropdown.css:51:background: #ffffff;

Significado: Los componentes están evitando tokens; probablemente fallarán en algún tema.

Decisión: Reemplaza con variables semánticas (por ejemplo, var(--color-text), var(--color-surface)) y permite hex crudos solo en archivos de tokens.

Tarea 3: Confirmar que la raíz HTML tiene el atributo esperado en reposo

cr0x@server:~$ node -e "const {JSDOM}=require('jsdom'); const html='<!doctype html><html data-theme=\"dark\"></html>'; const dom=new JSDOM(html); console.log(dom.window.document.documentElement.getAttribute('data-theme'));"
dark

Significado: Tus plantillas/SSR pueden establecer el atributo.

Decisión: Si SSR nunca establece data-theme, debes depender del bootstrap cliente y aceptar posible parpadeo—o implementar selección por cookie en SSR.

Tarea 4: Validar comportamiento de persistencia en localStorage en una ejecución headless

cr0x@server:~$ node -e "console.log('Simulate: read=system when empty, store=dark');"
Simulate: read=system when empty, store=dark

Significado: Este es un paso de sanity placeholder para scripting en CI: asegúrate de que tus rutas de código manejan valores faltantes/invalidos y no crashean.

Decisión: Si tienes excepciones lanzadas por acceso a storage en modo privado o entornos endurecidos, añade try/catch y por defecto usa system.

Tarea 5: Revisar cabeceras CSP para viabilidad de script inline

cr0x@server:~$ curl -sI http://localhost:3000 | rg -i 'content-security-policy'
content-security-policy: default-src 'self'; script-src 'self'

Significado: Los scripts en línea están bloqueados (no hay 'unsafe-inline', ni nonce, ni hash).

Decisión: O añade un nonce/hash para el pequeño bootstrap, o usa selección SSR por cookie para evitar parpadeos.

Tarea 6: Confirmar que el servidor establece una cookie de tema cuando el usuario elige una anulación

cr0x@server:~$ curl -sI http://localhost:3000/set-theme?value=dark | rg -i 'set-cookie'
set-cookie: theme=dark; Path=/; SameSite=Lax

Significado: El servidor puede persistir una anulación de forma que SSR pueda leerla.

Decisión: Si no ves Set-Cookie, SSR no puede saber de anulaciones; necesitarás la vía de bootstrap inline.

Tarea 7: Validar que la cookie se envía de vuelta en la siguiente petición

cr0x@server:~$ curl -sI --cookie "theme=dark" http://localhost:3000 | rg -i 'data-theme|set-cookie'
set-cookie: session=...; Path=/; HttpOnly; SameSite=Lax

Significado: La cookie está presente y la petición tuvo éxito. (No verás data-theme en cabeceras; verificarás el HTML a continuación.)

Decisión: Procede a obtener HTML e inspeccionar si SSR usó la cookie.

Tarea 8: Inspeccionar HTML SSR por corrección del atributo de tema

cr0x@server:~$ curl -s --cookie "theme=dark" http://localhost:3000 | head -n 5





Significado: SSR está renderizando el tema correcto inmediatamente.

Decisión: Si SSR sigue emitiendo system, tu servidor no está leyendo la cookie (o el orden del middleware es incorrecto).

Tarea 9: Confirmar que tu CSS declara color-scheme para alineación de UI nativa

cr0x@server:~$ rg -n 'color-scheme:\s*light\s+dark' src/**/*.css
src/styles/theme.css:2:  color-scheme: light dark;

Significado: Los navegadores tienen una pista para tematizar widgets nativos.

Decisión: Si falta, añádelo; luego vuelve a probar controles de formulario y barras de desplazamiento en distintas plataformas.

Tarea 10: Detectar overrides accidentales de tema por CSS de terceros

cr0x@server:~$ rg -n 'background:\s*#fff|color:\s*#000' node_modules/some-widget/dist/widget.css
node_modules/some-widget/dist/widget.css:88:background: #fff;
node_modules/some-widget/dist/widget.css:89:color: #000;

Significado: Una hoja de estilos de un proveedor tiene colores codificados y se verá mal en modo oscuro.

Decisión: Encapsúlala (shadow DOM o contenedor con overrides), parchea mediante variables CSS si es posible, o reemplaza el widget. No esperes que se arregle solo.

Tarea 11: Verificar que los estilos de impresión no imprimen una página negra

cr0x@server:~$ rg -n '@media\s+print' src/styles/*.css
src/styles/print.css:1:@media print {

Significado: Tienes manejo explícito para impresión.

Decisión: Si falta, añade una hoja de estilos de impresión que fuerce fondo claro y texto oscuro, independientemente del tema, a menos que tu producto requiera impresión oscura.

Tarea 12: Comprobar que la reducción de movimiento se respeta en transiciones de tema

cr0x@server:~$ rg -n 'prefers-reduced-motion' src/styles/**/*.css
src/styles/motion.css:1:@media (prefers-reduced-motion: reduce) {

Significado: Al menos has considerado la sensibilidad al movimiento.

Decisión: Si falta y tienes transiciones en colores/fondos, implementa la protección de reduced-motion.

Tarea 13: Comprobar que tu interruptor es accesible y está etiquetado (verificación estática)

cr0x@server:~$ rg -n 'aria-label="Theme"|aria-pressed|role="switch"' src/
src/components/ThemeToggle.tsx:18:<button aria-label="Theme" aria-pressed={...}>

Significado: Tu interruptor probablemente expone estado a tecnologías asistivas.

Decisión: Si no hay etiquetado o atributo de estado, arréglalo antes de lanzar; las regresiones de accesibilidad no son “agradables de tener”.

Tarea 14: Asegurarte de que el build no reordenó CSS rompiendo precedencia

cr0x@server:~$ ls -lh dist/assets/*.css
-rw-r--r-- 1 cr0x cr0x 214K Dec 29 10:22 dist/assets/app.9c31.css
-rw-r--r-- 1 cr0x cr0x  48K Dec 29 10:22 dist/assets/vendor.1a02.css

Significado: Tienes varios archivos CSS; el orden importa.

Decisión: Asegura que las definiciones de tokens carguen antes del CSS de componentes, y que los overrides de tema carguen después de los valores por defecto. Si el CSS del proveedor carga al final, puede anular tus colores.

Guía rápida de diagnóstico

Cuando el modo oscuro está “roto”, no empieces reescribiendo CSS. Empieza encontrando dónde diverge la verdad: preferencia almacenada, atributo DOM, tokens computados o estilos de componentes.

Primero: determina la preferencia seleccionada y el tema efectivo

  • Inspecciona document.documentElement.dataset.theme en la consola de DevTools.
  • Revisa el valor en storage/cookie para theme-preference (o la clave que uses).
  • Revisa la preferencia OS/navegador: ¿coincide prefers-color-scheme: dark con lo que piensas?

Si la preferencia es incorrecta: tu lógica de interruptor/persistencia está rota. Arregla JS y storage primero.

Segundo: verifica el comportamiento de la primera pintura (caza del parpadeo)

  • Forzar recarga con caché deshabilitada. Observa el flash.
  • Comprueba si data-theme está presente en el HTML SSR o se establece temprano mediante un script inline.
  • Revisa CSP: si los scripts inline están bloqueados y SSR no establece el tema, tendrás parpadeo.

Si la primera pintura es incorrecta: mueve la selección de tema más temprano (cookie SSR o bootstrap inline).

Tercero: aislar fallos de tokens CSS vs colores codificados en componentes

  • Inspecciona un componente ilegible y observa estilos calculados: ¿los valores vienen de var(--...) o de colores literales?
  • Si son colores literales: el componente evitó tokens o CSS de terceros lo sobrescribió.
  • Si se usan tokens pero están mal: los valores de token no se están sobrescribiendo para el tema activo.

Si los tokens no se sobrescriben: arregla la especificidad/orden de selectores (:root[data-theme="dark"] no aplicado), y verifica el orden de carga del CSS.

Errores comunes: síntomas → causa raíz → solución

1) Síntoma: el modo oscuro funciona tras alternar, pero se restablece al refrescar

Causa raíz: el estado se guarda en memoria (estado del framework), no se persiste; o la escritura a storage falla (modo privado, storage bloqueado, excepciones).

Solución: persiste theme-preference en localStorage con try/catch; por defecto usa system cuando el storage no esté disponible.

2) Síntoma: la página parpadea en claro y luego cambia a oscuro

Causa raíz: data-theme se aplica después de la primera pintura (el bundle carga tarde), o SSR renderizó un tema distinto al que el cliente computa.

Solución: incluye un script bootstrap mínimo inline (nonce/hash si es necesario), o renderiza la anulación vía cookie en SSR. Mantén el markup neutral al tema; varía presentación con tokens.

3) Síntoma: solo algunos componentes cambian de tema

Causa raíz: componentes con colores codificados o que usan su propio mecanismo de tema; múltiples raíces de tema existen.

Solución: exige uso de tokens; haz de <html data-theme> la única fuente de verdad; elimina clases de tema competidoras.

4) Síntoma: formularios se ven mal (inputs blancos sobre fondo oscuro)

Causa raíz: falta la declaración color-scheme; controles nativos no informados; estilos de componentes parcialmente sobrescritos.

Solución: establece color-scheme: light dark; en :root; estiliza controles de formulario explícitamente con tokens cuando sea necesario.

5) Síntoma: gráficos ilegibles en modo oscuro

Causa raíz: paleta de visualización de datos ajustada para fondos claros; líneas de cuadrícula/ejes con bajo contraste; canvas/SVG no tematizados.

Solución: define tokens específicos para gráficos (eje, cuadrícula, paleta de series) y cámbialos por tema; prueba con datos reales, no con demo.

6) Síntoma: los cambios de preferencia del sistema no se reflejan cuando está en “system”

Causa raíz: falta la media query CSS o está sobrescrita; la app almacenó un binario dark/light y nunca reevalúa.

Solución: almacena preferencia de tres estados; usa @media (prefers-color-scheme: dark) para los overrides de data-theme="system".

7) Síntoma: el interruptor es inaccesible o confuso

Causa raíz: control solo con icono sin etiqueta; estado no comunicado (aria-pressed ausente); estado “system” no representado.

Solución: usa un botón etiquetado o un switch; incluye opciones “System / Light / Dark” o un ciclo con tooltip claro y nombre accesible.

8) Síntoma: salida de impresión/PDF negra o desperdicio de tinta

Causa raíz: los estilos de tema oscuro se aplican a la impresión; no hay overrides de impresión.

Solución: añade CSS de impresión que fuerce paleta clara y texto oscuro, y desactive fondos decorativos.

Listas de verificación / plan paso a paso

Plan de implementación paso a paso (el patrón que resiste)

  1. Define el modelo de preferencia: system|light|dark. Escríbelo; hazlo parte del contrato de producto.
  2. Elige una vía de persistencia: localStorage para cliente-only; cookie si SSR necesita corrección de primera pintura para anulaciones.
  3. Implementa una raíz de tema: <html data-theme="system">. Todo lo demás la referencia.
  4. Construye tokens semánticos: background, surface, text, muted, border, focus, link, sombras.
  5. Implementa overrides CSS: :root[data-theme="dark"] y @media (prefers-color-scheme: dark) :root[data-theme="system"].
  6. Define color-scheme: color-scheme: light dark; en :root.
  7. Bootstrap inline (o cookie SSR): asegura que data-theme esté presente antes de la primera pintura.
  8. Construye la UI de interruptor: etiqueta accesible, exposición de estado (aria-pressed o role="switch"), manejo claro de system.
  9. Audita componentes por colores crudos: elimínalos o pásalos a tokens.
  10. Prueba flujos críticos en ambos temas: auth, ajustes, tablas, gráficos, modales, toasts, páginas de error.
  11. Maneja impresión: fuerza una paleta amigable para impresión.
  12. Añade observabilidad: trackea uso del interruptor y tasa de mismatch en primera pintura (muestreado).

Checklist de lanzamiento (qué verificar antes de enviar)

  • Recarga forzada en red lenta: sin flash del tema incorrecto.
  • El tema persiste entre recargas y en una nueva pestaña.
  • El modo sistema sigue cambios del SO sin sobrescribir la preferencia almacenada.
  • Formularios, modales y dropdowns legibles en ambos temas.
  • Anillos de foco visibles y consistentes.
  • Gráficos y tablas pasan la “prueba de entrecerrar ojos” en modo oscuro.
  • Salida de impresión sensata.
  • Widgets de terceros no rompen el tema.

Preguntas frecuentes

¿El interruptor debe ser de dos estados o de tres estados (system/light/dark)?

Tres estados gana en el mundo real. Dos estados te obliga a adivinar qué significa “apagado” y hace que los usuarios queden anclados a una elección antigua cuando la preferencia del SO cambia.

¿Es suficiente prefers-color-scheme sin un interruptor?

No. Algunos usuarios quieren lo contrario de la preferencia del sistema para un sitio específico (trabajo vs dispositivos personales, oficinas brillantes, deslumbramiento). Dales una anulación.

¿Dónde debe vivir el atributo de tema: <html> o <body>?

<html> es la raíz más limpia y funciona bien con declaraciones de tokens en :root. Elige una y sé consistente.

¿Por qué no simplemente alternar una clase .dark?

Puedes, pero los selectores de atributo son más fáciles de extender (system/light/dark) y reducen colisiones con clases no relacionadas. La gran ventaja es la consistencia, no la sintaxis.

¿Necesito escuchar cambios de tema del sistema en JavaScript?

No estrictamente, si tu CSS usa @media (prefers-color-scheme) para data-theme="system". Un listener puede ayudar a actualizar etiquetas/iconos de UI, pero no reescribas la preferencia almacenada.

¿Cómo evito mismatch de hidratación en frameworks SSR?

Mantén la estructura DOM igual entre temas. Usa variables CSS para diferencias de estilo. Si debes cambiar el markup, decide el tema del lado servidor via cookie para anulaciones.

¿Cómo deberíamos nombrar tokens?

Nombrar por intención: --color-surface, no --gray-950. Tu yo futuro te lo agradecerá cuando la marca cambie. Tu yo presente también, en silencio.

¿El modo oscuro siempre ahorra batería?

No siempre. En OLED, a menudo sí. En LCD, la retroiluminación permanece, así que el ahorro es menor. Lanza modo oscuro por usabilidad y preferencia primero; trata el ahorro de energía como un efecto colateral agradable.

¿Y los modos de alto contraste / forced colors?

No luches contra ellos. Evita imágenes de fondo codificadas detrás de texto, mantén visibles los estilos de foco y prueba el comportamiento de forced-colors. Si tu diseño falla ahí, no es “caso borde”; es deuda de accesibilidad.

¿Deberíamos animar las transiciones de tema?

Generalmente: no, o mantenlo extremadamente sutil y desactivado bajo prefers-reduced-motion. El tema es un estado, no una discoteca.

Conclusión: próximos pasos que puedes enviar

Un modo oscuro bien hecho es un problema de fiabilidad disfrazado de elección de diseño. Respeta la preferencia del sistema, ofrece una anulación, persiste de forma predecible y asegura la primera pintura. Usa tokens para que el sistema escale y añade la observabilidad justa para detectar regresiones antes que tus usuarios.

Pasos prácticos siguientes

  1. Implementa la preferencia de tres estados y establece data-theme en <html>.
  2. Mueve todos los valores de tema a variables CSS semánticas y elimina colores codificados en componentes.
  3. Añade un script bootstrap inline (CSP-safe via nonce/hash) o selección SSR por cookie para anulación y eliminar parpadeos.
  4. Ejecuta las auditorías basadas en grep (hex crudos, falta de color-scheme, CSS de proveedor que pisa) y arregla los peores primero.
  5. Prueba flujos críticos en ambos temas y en impresión, luego asegura con comprobaciones automatizadas.
← Anterior
Refrigeración líquida para GPUs: ¿decisión inteligente o cosplay caro?
Siguiente →
ATI vs NVIDIA: por qué esta rivalidad nunca termina

Deja un comentario