Módulo 6: Responsive And Dark Mode

La variante `dark:`

Descripción

Los prefijos de las lecciones 2 y 3 reaccionaban al ancho: md:p-4 es "usa este padding a partir de 768px". La variante dark: es el mismo mecanismo de prefijo aplicado a otra condición: el tema. dark:bg-gray-900 es "usa este fondo cuando el tema sea oscuro". No reacciona al tamaño de la pantalla, sino a si el modo oscuro está activo. Esa es la idea central de la lección: dark: es un prefijo condicional más —como md: o hover:—, solo que su condición es "el tema es oscuro" en vez de "hay al menos 768px" o "el cursor está encima".

Con dark: escribes el par claro/oscuro en la misma class="": bg-white dark:bg-gray-900 significa "fondo blanco por defecto, pero gris muy oscuro cuando el tema es oscuro". La versión sin prefijo (bg-white) es el caso claro —la base—; la versión con dark: (dark:bg-gray-900) la reemplaza cuando el modo oscuro está encendido. Un solo elemento, un solo atributo, los dos temas cubiertos —sin duplicar el componente—.

Conexión con el módulo. Esta lección abre la dimensión del tema, después de cerrar la del tamaño (lecciones 2-3). Reusa exactamente el modelo mental de los prefijos —una condición que decide si una utilidad aplica— y lo apunta a una condición nueva. Es el primer paso del dark mode; la lección 5 verá cómo se decide que el tema es oscuro (las estrategias media vs class), y la lección 6 mostrará una forma mejor de hacer color en dark —por tokens, sin escribir un dark: por elemento—. Aquí nos quedamos en la variante en sí: qué es, cómo se lee, cuándo la escribes. La resolución de qué utilidad queda activa por tema se ejecuta en Node; el JSX real se muestra.

Una analogía: el interruptor que enciende otra bombilla

Imagina una lámpara con dos bombillas y un interruptor de dos posiciones. En la posición de día, enciende la bombilla blanca y brillante. En la posición de noche, apaga esa y enciende una ámbar, tenue. No es que la misma bombilla "cambie de color"; hay dos bombillas, y el interruptor elige cuál está encendida según la hora.

dark: es ese interruptor eligiendo bombilla. bg-white dark:bg-gray-900 tiene dos "bombillas" de fondo: la blanca (bg-white) y la oscura (dark:bg-gray-900). El tema es el interruptor: en modo claro, enciende bg-white; en modo oscuro, apaga esa y enciende dark:bg-gray-900. Escribes las dos bombillas en la misma cadena; el tema elige cuál aplica.

Guarda la imagen y también su límite, porque marca la diferencia con la lección 6. Con dark:pones dos bombillas explícitas en cada lámpara —una clara y una oscura, escritas a mano en cada elemento—. Funciona, pero si tienes cien lámparas, escribiste doscientas bombillas. La lección 6 mostrará la alternativa del sistema: en vez de dos bombillas por lámpara, una sola lámpara conectada a un token que cambia de foco según el tema —cambias el foco central una vez y las cien lámparas cambian—. dark: es la forma directa y explícita; el token es la forma de sistema. Empezamos por la directa porque es la que hace visible el mecanismo.

El mecanismo: dark: es un prefijo condicional

Recuerda cómo leías md:p-4: "aplica p-4 cuando se cumpla la condición del prefijo (ancho >= 768px)". dark:bg-gray-900 se lee igual: "aplica bg-gray-900 cuando se cumpla la condición del prefijo (el tema es oscuro)". La estructura es idéntica —condicion:utilidad—; solo cambia la condición.

En el marcado, el par claro/oscuro vive en la misma cadena, como el par móvil/desktop vivía en la misma cadena en la lección 2:

<!-- el product-card: fondo y texto con su par claro/oscuro (esto se MUESTRA) -->
<article class="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-50">
  <h3 class="...">Auriculares inalambricos</h3>
  <p class="...">$1,299</p>
</article>

Léelo por propiedad. El fondo tiene su par: bg-white (claro) y dark:bg-gray-900 (oscuro). El texto tiene el suyo: text-gray-900 (casi negro, para leerse sobre blanco) y dark:text-gray-50 (casi blanco, para leerse sobre oscuro). En modo claro, el navegador aplica las versiones sin prefijo; en modo oscuro, activa las de dark: que reemplazan a su par. Fíjate en que el fondo y el texto se invierten juntos: claro sobre oscuro pasa a oscuro sobre claro, manteniendo el contraste —volveremos a esto en los errores comunes—.

Por debajo, dark: genera CSS condicional, igual que md: generaba una media query. Según la estrategia (lección 5), dark:bg-gray-900 se convierte en una regla dentro de @media (prefers-color-scheme: dark) o en una regla .dark .bg-gray-900. No necesitas saber cuál todavía —eso es la lección 5—; lo que importa ahora es que dark: es la forma de Tailwind de escribir "este estilo solo cuando el tema es oscuro", igual que md: era "este estilo solo a partir de 768px".

Y dark: se apila con otros prefijos. Puedes escribir md:dark:p-6 —"padding grande solo cuando hay 768px de ancho y el tema es oscuro"— combinando la condición de tamaño con la de tema. En la práctica esto es raro (el padding casi nunca depende del tema), pero muestra que dark: es un prefijo más en el mismo sistema, componible con los demás. Lo común es dark: sobre colores —fondos, texto, bordes—, porque el tema es, sobre todo, una decisión de color.

Ejemplo trabajado: qué utilidad queda activa en cada tema

Vamos a medir la variante. Tomamos el ejemplo canónico de la documentación de Tailwind —un fondo y un texto con su par claro/oscuro— y ejecutamos resolveClasses en los dos temas para ver qué utilidad queda activa en cada uno. El viewport lo fijamos en 1200px porque aquí no nos interesa el ancho, solo el tema:

// L4 — la variante dark:, que elige otra utilidad segun el tema (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);
}

// el ejemplo canonico de la doc de Tailwind: un color base y su override dark.
const card = 'bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-50';
console.log('=== dark: elige otra utilidad cuando el tema es oscuro ===\n');
console.log('cadena: "' + card + '"\n');
for (const theme of ['light', 'dark']) {
  const active = resolveClasses(card, { viewport: 1200, theme });
  console.log(`theme ${theme.padEnd(5)} -> ${active.join(' ')}`);
}

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

=== dark: elige otra utilidad cuando el tema es oscuro ===

cadena: "bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-50"

theme light -> bg-white text-gray-900
theme dark  -> dark:bg-gray-900 dark:text-gray-50

Lee las dos líneas como el interruptor cambiando de bombilla. La cadena de entrada fue una sola, con los cuatro estilos —el par de fondo y el par de texto—. En tema claro, quedaron activas las versiones sin prefijo: bg-white (fondo blanco) y text-gray-900 (texto casi negro). Las de dark: no aplican, porque la condición del prefijo (el tema es oscuro) no se cumple. En tema oscuro, se invierte: dark:bg-gray-900 (fondo casi negro) y dark:text-gray-50 (texto casi blanco) ganan, porque ahora sí se cumple la condición, y reemplazan a su par para la misma propiedad. Fondo y texto se intercambiaron de forma coherente —claro-sobre-oscuro se volvió oscuro-sobre-claro—, con una sola cadena y sin duplicar el componente.

Fíjate en cómo el modelo resuelve el par. Para la propiedad background hay dos candidatas: bg-white (sin dark) y dark:bg-gray-900 (con dark). En tema claro, la de dark: ni siquiera pasa el filtro —queda descartada de entrada—, así que gana bg-white. En tema oscuro, ambas pasan el filtro (la base siempre aplica), y entre las dos gana la de dark: porque es la más específica para ese tema. Es la misma lógica de "la condición más fuerte que se cumple gana" que veías en los breakpoints, ahora sobre el eje del tema.

Una pregunta para razonar el mecanismo: ¿qué pasaría si escribieras solo bg-white sin su par dark:bg-gray-900? (En tema oscuro no habría ninguna candidata con dark:, así que ganaría bg-white —un fondo blanco en modo oscuro—. Ese es el olvido más común: dejar un color sin su par oscuro, y que ese elemento se quede claro cuando todo lo demás oscureció. Con dark: explícito, cada color que deba cambiar necesita su par escrito a mano; olvidar uno deja un parche claro. La lección 6 quita justo ese problema —con tokens, no hay que escribir el par por elemento—.)

Profundización: dark: es para color, y el costo de escribirlo por elemento

En la práctica, dark: aparece casi siempre sobre colores: fondos (dark:bg-*), texto (dark:text-*), bordes (dark:border-*), sombras. Tiene sentido: el tema oscuro es, en esencia, una decisión de qué colores usar cuando la pantalla debe emitir poca luz. El layout —cuántas columnas, cuánto padding— casi nunca cambia con el tema; una tarjeta tiene la misma forma de día y de noche, solo cambia de color. Por eso rara vez verás dark:grid-cols-2 o dark:p-6, y muy seguido dark:bg-gray-900.

Ahora el costo, que es la bisagra hacia la lección 6. Con dark: explícito, cada elemento con color necesita su par escrito a mano. El product-card lleva bg-white dark:bg-gray-900 y text-gray-900 dark:text-gray-50; el Button, otro par; la SearchBar, otro; el navbar, otro. En una app con cien elementos de color, escribiste cien pares dark:. Y peor: si mañana decides que el gris oscuro del tema debe ser gray-800 en vez de gray-900, tienes que encontrar y cambiar cada dark:bg-gray-900 disperso por el código. Es exactamente el "drift del copy-paste" que el módulo 2 combatía con tokens, reaparecido en el eje del tema.

Esto no significa que dark: esté mal —es la forma correcta y directa, y la que la documentación de Tailwind enseña primero—. Significa que hay una forma mejor para un sistema: en vez de escribir el par claro/oscuro en cada elemento, haces que bg-surface apunte a un token que cambia de valor bajo .dark, y entonces el elemento lleva solo bg-surface —sin dark:— y el tema lo resuelve el token. Un cambio de foco central en vez de doscientas bombillas. Esa es la lección 6. Por ahora, quédate con dark: como la herramienta base: entiéndela bien, porque el token la reemplaza sin contradecirla —el token es dark: movido del elemento al sistema—.

Errores comunes

Dejar un color sin su par dark: y que se quede claro en modo oscuro. Qué pasa: se escribe bg-white en un elemento pero se olvida dark:bg-gray-900, y en modo oscuro ese elemento sigue blanco mientras todo lo demás oscureció —un parche cegador—. Por qué pasa: con dark: explícito, cada color depende de que recuerdes escribir su par; es fácil saltarse uno. Cómo detectarlo: en modo oscuro, un bloque queda claro y desentona; en el marcado, ese bloque tiene un color base sin su dark: correspondiente. Cómo corregirlo: agrega el par (dark:bg-gray-900). Y toma nota de por qué pasó —depender de escribir el par por elemento es frágil—: es el argumento para el theming por tokens de la lección 6, donde el color oscuro sale del token y no hay par que olvidar.

Poner dark: sobre layout en vez de sobre color. Qué pasa: se escribe dark:grid-cols-1 o dark:p-8, haciendo que el layout cambie con el tema. Por qué pasa: se confunde "el tema cambia cómo se ve" con "el tema cambia la estructura". Cómo detectarlo: al alternar claro/oscuro, no solo cambia el color sino que se reacomodan columnas o espaciados —desorientador, porque el usuario espera el mismo layout con otra luz—. Cómo corregirlo: dark: es para color; el layout no debe depender del tema. Una tarjeta tiene la misma forma de día y de noche. Si el layout cambia, casi siempre es un error, no una decisión.

Contraste que se ve bien en claro pero falla en oscuro. Qué pasa: el par oscuro se elige "a ojo" —dark:text-gray-400 sobre dark:bg-gray-900— y el texto queda demasiado tenue para leerse. Por qué pasa: uno verifica el contraste en el tema claro (donde diseñó) y asume que el oscuro "también estará bien". Cómo detectarlo: el texto en modo oscuro se lee con esfuerzo, o directamente no cumple el mínimo de contraste. Cómo corregirlo: el par oscuro necesita su propia verificación de contraste —el ratio entre dark:text-* y dark:bg-*, no el del tema claro—. Es el contrastRatio del módulo 4, corrido para el tema oscuro por separado. Un tema no está listo hasta que sus pares pasan AA en su combinación; remite al módulo 4 para medirlo.

Ejercicios

Ejercicio 1 — Resuelve por tema. Con el resolveClasses del ejemplo y la cadena bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700, sin correr nada, di qué utilidades quedan activas en cada tema.

Ver solución
themeactivaspor qué
lightbg-gray-100 border border-gray-300las de dark: no pasan el filtro; ganan las base
darkdark:bg-gray-800 border dark:border-gray-700dark:bg-gray-800 gana el fondo, dark:border-gray-700 gana el color de borde; border (que activa el borde, sin color) no tiene par y queda en los dos

Fíjate en border: es una utilidad de ancho/activación de borde, no de color, así que no tiene par dark: y aplica en ambos temas. El color del borde sí tiene par (border-gray-300 / dark:border-gray-700) y se resuelve por tema. Una misma zona (el borde) puede tener una parte constante (que exista) y una parte que cambia con el tema (su color).

Ejercicio 2 — Completa los pares faltantes. Este Button se ve bien en claro pero tiene un problema en oscuro. Identifícalo y agrega lo que falta:

<button class="bg-blue-600 text-white px-4 py-2 rounded-md">Add to cart</button>
Ver solución

El problema es que ningún color tiene par dark:: en modo oscuro, el botón se queda exactamente igual que en claro. A veces eso está bien (un botón azul con texto blanco puede funcionar en ambos temas), pero si el diseño pide un azul más suave en oscuro para no brillar demasiado, faltan los pares:

<button class="bg-blue-600 dark:bg-blue-500 text-white px-4 py-2 rounded-md">Add to cart</button>

Aquí text-white y el layout (px-4 py-2 rounded-md) se quedan igual en ambos temas —el texto blanco funciona sobre los dos azules, y la forma no cambia con el tema—; solo el fondo recibe su par oscuro (dark:bg-blue-500, un azul un poco más claro que brilla menos sobre fondo oscuro). La lección: no todo color necesita par —solo los que deben cambiar—; el criterio es si el color se ve bien en el otro tema, y eso se mide con contraste (módulo 4), no a ojo.

Ejercicio 3 — dark: frente a md:. Explica en una frase qué tienen en común dark:bg-gray-900 y md:grid-cols-4, y en qué se diferencian.

Ver solución

En común: los dos son prefijos condicionales —aplican su utilidad solo cuando se cumple una condición—, y los dos dejan que una sola class="" cubra varios estados sin duplicar el componente. Se diferencian en cuál es la condición: md: reacciona al ancho de la pantalla (aplica a partir de 768px), dark: reacciona al tema (aplica cuando el modo oscuro está activo). Son el mismo mecanismo —condicion:utilidad— apuntado a ejes distintos: uno al tamaño, otro al tema. Por eso se pueden apilar (md:dark:...): son condiciones independientes que se combinan.

Resumen y siguiente paso

En esta lección viste que dark: es un prefijo condicional más —como md: o hover:— cuya condición es "el tema es oscuro". Con el interruptor que enciende otra bombilla entendiste el principio: bg-white dark:bg-gray-900 pone dos "bombillas" de fondo en la misma cadena, y el tema elige cuál se enciende —la clara por defecto, la oscura cuando el modo oscuro está activo—, sin duplicar el componente. Y lo comprobaste ejecutando: la cadena bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-50 resolvió a las versiones claras en tema claro y a las de dark: en tema oscuro, con fondo y texto invirtiéndose de forma coherente.

Antes de avanzar deberías poder: leer un par bg-white dark:bg-gray-900 como base + override por tema; explicar por qué dark: va sobre color y no sobre layout; y ver el costo de escribir el par por elemento (el drift que la lección 6 elimina con tokens).

Quedó una pregunta abierta que la lección 5 responde: ¿cómo decide la página que el tema es oscuro? dark: aplica "cuando el tema es oscuro", pero ¿quién enciende ese interruptor —el sistema operativo del usuario, o un botón en la propia página? Hay dos estrategiasmedia (automática, sigue al sistema operativo vía prefers-color-scheme) y class (manual, un toggle que agrega .dark al <html>)— con implicaciones distintas. La lección 5 las compara y te ayuda a elegir cuál usa el storefront de Mercado.

Recursos