Módulo 6: Responsive And Dark Mode

Las dos estrategias de dark mode: `media` vs `class`

Descripción

En la lección anterior, dark:bg-gray-900 aplicaba "cuando el tema es oscuro" —pero dejamos abierta la pregunta de quién decide que el tema es oscuro—. Esta lección la responde: hay dos estrategias, y las configuras en el tailwind.config con la opción darkMode. La estrategia media hace que dark: siga el modo oscuro del sistema operativo del usuario, vía la media query prefers-color-scheme —automático, sin que la página haga nada—. La estrategia class hace que dark: dependa de una clase .dark en un ancestro (típicamente <html>) —manual, controlado por un toggle en la propia página—. Esa es la decisión central de la lección: ¿el tema lo decide el sistema operativo del usuario, o le das al usuario un botón para elegirlo en tu sitio?

Las dos son válidas y resuelven necesidades distintas. media es cero esfuerzo y respeta la preferencia global del usuario: si tiene su teléfono en modo oscuro, tu sitio sale oscuro, y punto. class da control explícito: un toggle sol/luna en el sitio, que el usuario acciona aunque su sistema esté en claro —y que suele recordar su elección entre visitas—. La mayoría de las apps de producto que quieren un toggle propio usan class; los sitios que solo quieren "respetar el modo del sistema" usan media.

Conexión con el módulo. Esta lección completa la variante dark: de la lección 4 explicando de dónde sale la condición "el tema es oscuro". Es también el puente hacia la lección 6: el theming por tokens necesita la estrategia class (el toggle .dark) para funcionar con el override de tokens que viste en el módulo 2 —el bloque .dark que redefine las custom properties es exactamente la clase de esta estrategia—. La decisión de tema según la estrategia se ejecuta en Node; los dos tailwind.config reales se muestran.

Una analogía: el sensor automático y el interruptor manual

Vuelve a la lámpara de la lección 4, pero ahora fíjate en cómo se acciona el interruptor. Hay dos maneras de que una casa cambie a "luz de noche".

La primera es un sensor automático de luz ambiente: al atardecer, cuando la luz de afuera baja, el sensor enciende solo la iluminación cálida. Nadie toca nada; la casa sigue la hora del día. Es cómodo —siempre acierta con el momento— pero el habitante no manda: si a las 3 de la tarde quiere ambiente de noche para ver una película, el sensor no se lo da, porque afuera todavía hay sol.

La segunda es un interruptor manual: un botón en la pared que el habitante acciona cuando quiere. Da control total —enciende la luz de noche cuando le da la gana, sin importar la hora— pero exige una acción y algo de infraestructura: alguien tuvo que instalar el interruptor y cablearlo.

Las dos estrategias de dark mode son ese sensor y ese interruptor. media es el sensor automático: la página sigue el modo oscuro del sistema operativo (la "hora del día" del usuario) sin que nadie toque nada. class es el interruptor manual: un toggle en el sitio que el usuario acciona, con control total sobre el tema, a cambio de instalar el interruptor (un poco de JavaScript que agrega o quita la clase .dark y recuerda la elección). La moraleja es la de siempre: eliges según si quieres comodidad automática o control explícito —y muchas apps quieren el control, por eso instalan el interruptor—.

El mecanismo: dos configuraciones, dos condiciones

Nota de versión — Tailwind v4. Estas lecciones configuran el dark mode con darkMode: 'class'/'media' en el tailwind.config.js (Tailwind v3). En Tailwind v4 (enero 2025) el dark mode por clase se declara en CSS con @custom-variant dark (&:where(.dark, .dark *)); en lugar de darkMode: 'class'. La estrategia (seguir al sistema con media, o togglear una clase .dark) y todo lo demás de esta lección son idénticos; solo cambia esa línea de configuración. Para usar este tailwind.config.js con v4, cárgalo con @config.

La estrategia se elige en el tailwind.config con la opción darkMode. Con media (el comportamiento tradicional por defecto), dark: se ata a prefers-color-scheme:

// tailwind.config.js — estrategia media: dark: sigue al sistema operativo (esto se MUESTRA)
export default {
  darkMode: 'media',
  // ...
};

Con esto, dark:bg-gray-900 genera, por debajo, una regla dentro de @media (prefers-color-scheme: dark). El navegador la activa cuando el sistema operativo del usuario está en modo oscuro. La página no hace nada; la condición la evalúa el navegador leyendo la preferencia del sistema. No hay toggle, no hay JavaScript, no hay forma de que el usuario elija un tema distinto al de su sistema dentro de tu sitio.

Con class, dark: se ata a la presencia de una clase .dark en un ancestro:

// tailwind.config.js — estrategia class: dark: depende de .dark en <html> (esto se MUESTRA)
export default {
  darkMode: 'class',
  // ...
};

Ahora dark:bg-gray-900 genera la regla .dark .bg-gray-900 (una regla CSS descendiente, no una media query). Aplica cuando algún ancestro del elemento tiene la clase .dark —normalmente el <html>—. Y aquí entra el trabajo extra: alguien tiene que poner y quitar esa clase. Ese "alguien" es un poco de JavaScript, el toggle:

// el toggle del sitio: agrega o quita .dark en <html> (esto se MUESTRA, es del navegador)
function toggleTheme() {
  const isDark = document.documentElement.classList.toggle('dark');
  localStorage.setItem('theme', isDark ? 'dark' : 'light'); // recuerda la eleccion
}

// al cargar la pagina, restaura la eleccion previa (o sigue al sistema como default):
const saved = localStorage.getItem('theme');
if (saved === 'dark' || (!saved && matchMedia('(prefers-color-scheme: dark)').matches)) {
  document.documentElement.classList.add('dark');
}

Fíjate en las tres piezas que la estrategia class necesita y que media no: (1) un toggle que agrega/quita .dark; (2) memoria (localStorage) para recordar la elección entre visitas; y (3) una restauración temprana al cargar, para que la página no aparezca en claro y luego "salte" a oscuro (ese parpadeo se llama FOUC —flash of unstyled content— y se evita corriendo la restauración antes de pintar). media no necesita nada de esto porque el navegador resuelve todo; a cambio, no da control al usuario. El control cuesta infraestructura.

Ejemplo trabajado: misma preferencia de sistema, distinta estrategia

Lo que revela la diferencia es un escenario donde la preferencia del sistema y la elección del usuario no coinciden. Imagina un usuario cuyo sistema operativo está en modo oscuro, pero que —dentro de tu sitio— apagó el toggle porque prefiere leer este sitio en claro. ¿Qué tema sale, según la estrategia?

Modelamos la decisión con activeTheme(strategy, env): la estrategia media lee prefersDark (la preferencia del sistema); la estrategia class lee htmlHasDark (si el toggle puso la clase). Luego alimentamos ese tema a resolveClasses para ver qué fondo queda activo:

// L5 — las dos estrategias de darkMode: 'media' vs 'class' (modelo pedagogico).
const BREAKPOINTS = { sm: 640, md: 768, lg: 1024, xl: 1280 };

function propertyOf(base) {
  if (['flex', 'inline-flex', 'grid', 'block'].includes(base)) return 'display';
  if (base.startsWith('grid-cols-')) return 'grid-cols';
  if (base.startsWith('gap-')) return 'gap';
  if (base.startsWith('p-')) return 'padding';
  if (base.startsWith('bg-')) return 'background';
  if (/^text-(xs|sm|base|lg|xl|2xl|3xl)$/.test(base)) return 'font-size';
  if (base.startsWith('text-')) return 'text-color';
  return base;
}
function parseClass(raw) {
  const parts = raw.split(':');
  const base = parts.pop();
  let bp = null, dark = false;
  for (const p of parts) { if (p in BREAKPOINTS) bp = p; else if (p === 'dark') dark = true; }
  return { raw, base, bp, dark };
}
function resolveClasses(classList, { viewport, theme }) {
  const parsed = classList.split(/\s+/).filter(Boolean).map(parseClass);
  const active = parsed.filter(c =>
    (c.bp === null || viewport >= BREAKPOINTS[c.bp]) && (!c.dark || theme === 'dark'));
  const winners = new Map();
  for (const c of active) {
    const prop = propertyOf(c.base);
    const score = (c.bp ? BREAKPOINTS[c.bp] : 0) * 2 + (c.dark ? 1 : 0);
    const cur = winners.get(prop);
    if (!cur || score > cur.score) winners.set(prop, { raw: c.raw, score });
  }
  const keep = new Set([...winners.values()].map(w => w.raw));
  return parsed.filter(c => keep.has(c.raw)).map(c => c.raw);
}

// la estrategia decide DE DONDE sale el tema activo:
//  - 'media' lee prefers-color-scheme (el modo oscuro del sistema operativo).
//  - 'class' lee si hay una clase .dark en <html> (el toggle manual del sitio).
function activeTheme(strategy, env) {
  if (strategy === 'media') return env.prefersDark ? 'dark' : 'light';
  if (strategy === 'class') return env.htmlHasDark ? 'dark' : 'light';
  throw new Error('estrategia desconocida: ' + strategy);
}

// escenario revelador: el SO esta en oscuro, pero el usuario apago el toggle del sitio.
const env = { prefersDark: true, htmlHasDark: false };
const card = 'bg-white dark:bg-gray-900';
console.log('=== misma preferencia de SO, distinta estrategia ===\n');
console.log('entorno: prefersDark=' + env.prefersDark + ', htmlHasDark=' + env.htmlHasDark + '\n');
for (const strategy of ['media', 'class']) {
  const theme = activeTheme(strategy, env);
  const active = resolveClasses(card, { viewport: 1200, theme });
  console.log(`${strategy.padEnd(5)} -> tema ${theme.padEnd(5)} -> ${active.join(' ')}`);
}

Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:

=== misma preferencia de SO, distinta estrategia ===

entorno: prefersDark=true, htmlHasDark=false

media -> tema dark  -> dark:bg-gray-900
class -> tema light -> bg-white

Lee las dos líneas como el sensor y el interruptor decidiendo distinto ante el mismo entorno. El entorno es el mismo para las dos: el sistema operativo está en oscuro (prefersDark=true) y el toggle del sitio está apagado (htmlHasDark=false). Con media (el sensor automático), el tema sale dark —la página sigue al sistema operativo, ignorando que el usuario apagó el toggle, porque en esta estrategia el toggle ni existe—: queda activo dark:bg-gray-900. Con class (el interruptor manual), el tema sale light —la página obedece al toggle, que el usuario apagó, ignorando que el sistema está en oscuro—: queda activo bg-white. La misma condición del sistema produce temas opuestos según la estrategia, y ese es exactamente el punto de la decisión.

Fíjate en lo que cada estrategia prioriza. media prioriza la preferencia global del usuario: "tu sistema está en oscuro, así que todo, incluido este sitio, sale oscuro". No hay forma de contradecirlo desde el sitio —para bien (consistencia) y para mal (cero control local)—. class prioriza la elección local del usuario en tu sitio: "decidiste ver este sitio en claro, y eso mando, aunque tu sistema esté en oscuro". Da control fino, a cambio de que tú instales y mantengas el toggle. No hay una "correcta": hay una que respeta la preferencia del sistema sin preguntar, y otra que le da al usuario un botón.

Una pregunta para razonar la elección de Mercado: si el storefront quiere un toggle sol/luna en su navbar —para que el usuario elija el tema del sitio aunque su sistema esté en claro—, ¿qué estrategia necesita? (class. El toggle es agregar y quitar .dark, que es justo lo que la estrategia class lee. Con media no podrías ofrecer ese botón, porque dark: seguiría rígidamente al sistema operativo, sordo a cualquier control del sitio. Por eso las apps de producto con toggle propio usan class —y por eso la lección 6, que teje el theming por tokens con .dark, asume esta estrategia—.)

Profundización: por qué el sistema de diseño prefiere class

Para un sistema de diseño —el tema de este módulo— la estrategia class es casi siempre la elegida, y vale la pena ver por qué, porque conecta con el módulo 2. Recuerda que allá el theming se hacía con un bloque .dark que redefinía las custom properties: :root daba los valores del tema claro y .dark los redefinía con los oscuros. Esa clase .dark es exactamente la de la estrategia class. Cuando el toggle agrega .dark al <html>, dos cosas pasan a la vez: las variantes dark: de Tailwind se activan, y las custom properties de tus tokens se redefinen a sus valores oscuros. Una sola clase gobierna el tema entero —las utilidades dark: y los tokens—.

Con media no tendrías ese punto de control único. La media query prefers-color-scheme activa las variantes dark:, pero no puede "agregar una clase" para redefinir tus tokens —tendrías que duplicar el override de tokens dentro de un @media (prefers-color-scheme: dark), en paralelo al bloque .dark—. La estrategia class unifica todo bajo una clase que tú controlas: es la que hace que "cambiar de tema" sea un solo interruptor que enciende tanto las variantes como los tokens. Por eso la lección 6 —theming por tokens en dark— se construye sobre class, no sobre media.

Esto no descarta media. Un blog, una landing, un sitio de contenido que solo quiere "respetar el modo del sistema" y no necesita un toggle propio: media es perfecto, cero código. Pero un producto con sistema de diseño, tokens y un toggle sol/luna —el caso de Mercado— quiere class. La regla práctica: ¿necesitas un toggle en el sitio o redefinir tokens por tema? → class. ¿Solo quieres seguir al sistema operativo sin más? → media.

Errores comunes

Olvidar poner .dark en el <html> con la estrategia class. Qué pasa: se configura darkMode: 'class' y se escriben los pares dark:..., pero nunca se agrega la clase .dark a ningún ancestro —no hay toggle, o el toggle no toca <html>—. Por qué pasa: con media el tema "funcionaba solo", así que uno espera lo mismo de class y olvida que ahora debes encender el interruptor. Cómo detectarlo: el modo oscuro nunca aparece, por más que el sistema operativo esté en oscuro; los dark:... no se activan jamás. Cómo corregirlo: la estrategia class requiere que algo agregue .dark al <html> —el toggle, o la restauración temprana al cargar—. Sin esa clase, dark: no tiene de dónde activarse. Es el error inverso al de media: allá el navegador enciende solo; aquí, si no lo enciendes tú, no enciende nadie.

El parpadeo claro→oscuro al cargar (FOUC) con class. Qué pasa: la página carga en claro y, un instante después, "salta" a oscuro cuando el JavaScript del toggle corre y agrega .dark. Por qué pasa: si la restauración del tema corre después de que la página pintó, el usuario ve un frame en el tema equivocado. Cómo detectarlo: un destello blanco al abrir el sitio en modo oscuro. Cómo corregirlo: corre la restauración de .dark antes de pintar —típicamente un script pequeño y síncrono en el <head>, antes del contenido—, para que <html> ya tenga (o no) la clase cuando el navegador dibuja el primer frame. Es infraestructura que class exige y media no: el precio del control es manejar bien el momento de encender el interruptor.

Elegir media y luego querer un toggle en el sitio. Qué pasa: se configura darkMode: 'media' y más tarde el diseño pide un botón sol/luna para elegir el tema. Por qué pasa: media es el default cómodo, y el requisito del toggle aparece después. Cómo detectarlo: intentas hacer el toggle y descubres que agregar .dark no hace nada, porque con media las variantes siguen al sistema operativo, no a la clase. Cómo corregirlo: cambia a darkMode: 'class' e implementa el toggle (con memoria y restauración temprana). La elección de estrategia condiciona lo que podrás ofrecer: si hay chance de querer un toggle, empieza con class. Cambiar después es posible, pero implica agregar toda la infraestructura de golpe.

Ejercicios

Ejercicio 1 — Predice el tema. Con activeTheme(strategy, env) del ejemplo, sin correr nada, di qué tema sale en cada caso:

  • (a) media, con env = { prefersDark: false, htmlHasDark: true }
  • (b) class, con env = { prefersDark: false, htmlHasDark: true }
  • (c) class, con env = { prefersDark: true, htmlHasDark: false }
Ver solución
casoestrategialeetema
(a)mediaprefersDark (false)light
(b)classhtmlHasDark (true)dark
(c)classhtmlHasDark (false)light

Fíjate en (a) y (b): mismo entorno, temas opuestos. Con media, htmlHasDark es irrelevante —la estrategia ni lo mira, solo lee el sistema operativo (claro)—. Con class, prefersDark es irrelevante —solo cuenta si el toggle puso la clase (sí)—. Cada estrategia lee una sola fuente e ignora la otra por completo. Ese es el corazón de la decisión: qué fuente manda.

Ejercicio 2 — ¿Qué estrategia? Para cada sitio, di si conviene media o class y por qué:

  • (a) Un blog personal que solo quiere verse oscuro si el lector tiene su sistema en oscuro.
  • (b) El storefront de Mercado, que quiere un toggle sol/luna en el navbar y redefinir tokens por tema.
  • (c) Una app interna de dashboard donde cada usuario elige su tema y el sistema lo recuerda entre sesiones.
Ver solución
  • (a) media. Solo quiere seguir al sistema operativo, sin toggle ni control local. media da eso con cero código —el navegador resuelve todo—. Agregar class sería instalar un interruptor que nadie va a usar.
  • (b) class. Necesita un toggle en el sitio (que es agregar/quitar .dark) y redefinir tokens por tema (el bloque .dark del módulo 2). Las dos cosas dependen de la clase .dark; solo class las habilita. Es el caso del módulo, y por eso la lección 6 asume class.
  • (c) class. "Cada usuario elige su tema y se recuerda" es control local + memoria (localStorage), justo la infraestructura de la estrategia class. media no dejaría al usuario elegir un tema distinto al de su sistema.

La regla: ¿toggle propio o tokens por tema? → class. ¿Solo seguir al sistema? → media.

Ejercicio 3 — Diagnostica el toggle roto. Un equipo configuró darkMode: 'class', escribió los pares dark:..., e implementó un botón que corre document.documentElement.classList.toggle('dark'). Funciona: el sitio cambia de tema al hacer clic. Pero reportan dos bugs. Para cada uno, di la causa y el arreglo:

  • (a) Al recargar la página, el tema siempre vuelve a claro, aunque el usuario lo había puesto en oscuro.
  • (b) Al abrir el sitio (ya con oscuro guardado), se ve un destello blanco antes de que aparezca el tema oscuro.
Ver solución
  • (a) Falta memoria. El toggle cambia la clase pero no guarda la elección, así que cada recarga empieza sin .dark. Arreglo: al accionar el toggle, persiste la elección (localStorage.setItem('theme', isDark ? 'dark' : 'light')), y al cargar la página, léela y restaura la clase si corresponde. La clase .dark no sobrevive a una recarga por sí sola; hay que reponerla.
  • (b) Restauración tardía (FOUC). La restauración de .dark corre después de que la página pintó, así que el primer frame sale en claro y luego salta. Arreglo: corre la restauración antes de pintar —un script síncrono en el <head>, antes del contenido— para que <html> ya tenga .dark cuando el navegador dibuja. El control de la estrategia class incluye controlar cuándo se enciende el interruptor, no solo que se encienda.

Los dos bugs son el precio del control: media no los tendría (el navegador resuelve todo), pero tampoco daría el toggle. Con class, memoria y restauración temprana son parte del trabajo.

Resumen y siguiente paso

En esta lección viste que hay dos estrategias de dark mode: media (automática, sigue al sistema operativo vía prefers-color-scheme) y class (manual, un toggle que agrega .dark al <html>). Con el sensor automático y el interruptor manual entendiste el trade-off: media es cero esfuerzo y respeta la preferencia global del usuario, pero no da control local; class da un toggle propio y control fino, a cambio de instalar el interruptor (JavaScript, memoria, restauración temprana para evitar el FOUC). Y lo comprobaste ejecutando: ante el mismo entorno —sistema en oscuro, toggle apagado—, media resolvió a dark (sigue al sistema) y class a light (obedece al toggle). La misma condición, temas opuestos según quién manda.

Antes de avanzar deberías poder: configurar darkMode en media o class; explicar qué infraestructura exige class (toggle, memoria, restauración temprana); y decidir la estrategia según si el sitio necesita un toggle propio o solo seguir al sistema.

La lección 6 aprovecha la estrategia class para el paso que cierra el dark mode del sistema: el theming por tokens. Hasta ahora, cada color con dark: explícito necesitaba su par escrito a mano por elemento —el costo que vimos en la lección 4—. Con la clase .dark que esta lección instaló, vas a conectar el dark mode con los tokens del módulo 2: bg-surface apuntará a un token que cambia de valor bajo .dark, y entonces el elemento llevará solo bg-surface —sin dark:— mientras el tema lo resuelve el token. Es el cambio de foco central en vez de doscientas bombillas, y reusa el resolveToken que ya ejecutaste.

Recursos

  • Tailwind CSS, "Dark Mode" (secciones "Toggling dark mode manually" y "Supporting system preference") — tailwindcss.com/docs/dark-mode. Las dos estrategias (media y class), la config darkMode y el patrón del toggle con localStorage. En inglés.
  • MDN, "prefers-color-scheme" — developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme. La media query que sostiene la estrategia media: cómo el navegador expone la preferencia del sistema operativo. En inglés.
  • web.dev, "prefers-color-scheme: Hello darkness, my old friend" — web.dev/articles/prefers-color-scheme. Guía completa de dark mode en la web, incluido cómo evitar el FOUC y combinar preferencia del sistema con un toggle propio. En inglés.