Módulo 6: Responsive And Dark Mode

Presentación del módulo: responsive y dark mode en el sistema

De "componentes con variantes" a "componentes que además responden a tamaño y tema"

En el módulo 5 cerraste la capa de componentes. El Button de Mercado dejó de ser una cadena de clases suelta y se volvió un componente con variantes (variant: primary/secondary/ghost; size: sm/md/lg) resueltas por props, sobre los tokens del módulo 2, las utilidades de Tailwind del módulo 3 y las escalas del módulo 4. Hoy sabes pedirle a un componente "primary, grande" y recibir la combinación correcta de clases, sin duplicar nada.

Pero fíjate en lo que ese Button —y el ProductCard, y el catálogo entero— todavía no hacen. No se adaptan al tamaño de la pantalla: el catálogo que se ve bien en desktop, con cuatro columnas de tarjetas, en un teléfono se aprieta en cuatro columnas ilegibles. Y no se adaptan al tema: el bg-surface blanco que funciona de día enceguece de noche, y el usuario que puso su sistema en modo oscuro recibe una app que no lo respeta. Las variantes del módulo 5 resolvieron la apariencia por props —el look que tú eliges al montar el componente—. Faltan dos apariencias más que el componente debe manejar por contexto, no por props: la del tamaño y la del tema.

Esa es la capa que este módulo agrega, y la clave está en cómo lo hace: sin duplicar el componente. No vas a construir un ProductCardMobile y un ProductCardDesktop, ni un ProductCard claro y un ProductCardDark. Vas a tener un ProductCard cuya class="" lleva prefijos que Tailwind resuelve según el contexto: prefijos responsive (sm:, md:, lg:) que agregan estilos a partir de cierto ancho, y la variante dark: (o mejor, el theming por tokens del módulo 2) que cambia el color según el tema. Una sola cadena de clases que cubre móvil y desktop, claro y oscuro, todo a la vez.

Conexión con el módulo. Esta es la lección-mapa del módulo 6. No entra a fondo en ninguna pieza: instala la tesis (un componente responde a tamaño y tema por prefijos y tokens, sin duplicarse), da el mapa de las ocho lecciones, y ejecuta un primer teaser del resolveClasses —el modelo pedagógico del cascade de prefijos de Tailwind que usarás todo el módulo—. La lección 2 abre los prefijos responsive y el enfoque mobile-first. La 3 los presenta como una escala de breakpoints compartida por todo el sistema. La 4 introduce la variante dark:. La 5 compara las dos estrategias de dark mode (media vs class). La 6 conecta el dark con el theming por tokens del módulo 2 (el que evita el dark: por elemento). La 7 junta todo: un componente responsive y dark sin duplicación. Y la 8 te pone a hacer el catálogo de Mercado responsive y con dark mode.

Este módulo se apoya en dos cosas que ya tienes. Del ecosistema, en web-fundamentals-html-css (módulo 7, "Responsive design"): ahí aprendiste el CSS responsive a mano —el viewport, las media queries, mobile-first, elegir breakpoints—. Aquí no se re-enseña esa mecánica; se ve cómo Tailwind la expresa con prefijos, y se remite a web-fundamentals para el fondo. Y del módulo 2 de esta guía, el theming por tokens: dark: sobre tokens es el mismo "cambiar el valor del token semántico, no el componente" que ya ejecutaste con resolveToken. Como en todo el módulo, el JSX y el tailwind.config reales se muestran en los bloques (es lo que escribes); la resolución de prefijos se ejecuta en Node, porque el navegador no corre en un agente.

Una analogía: la casa que se amplía y la lámpara que cambia de foco

Piensa en dos decisiones distintas sobre una misma casa.

La primera es el tamaño. Cuando construyes con poco terreno, levantas la casa base: una recámara, una cocina, un baño —lo esencial, lo que cabe en el lote chico—. Si más adelante consigues terreno alrededor, no derrumbas la casa para hacer otra: le agregas cuartos hacia afuera —un estudio cuando hay algo de espacio, una sala grande cuando hay mucho—. La casa base sigue ahí; el terreno extra solo suma. Eso es mobile-first: la base de tus clases es la versión móvil (el lote chico), y los prefijos (md:, lg:) son los cuartos que se agregan cuando hay pantalla para ellos. No diseñas para desktop y recortas hacia el teléfono; diseñas para el teléfono y amplías hacia el desktop.

La segunda decisión es la luz. Cuando quieres que la casa se vea distinta de noche, no la re-decoras entera —no cambias los muebles, ni las paredes, ni los cuadros—. Cambias el foco de las lámparas: un foco cálido y tenue en vez del brillante de día. Los muebles son los mismos; lo que cambia es qué luz los baña. Eso es el dark mode por tokens: no construyes un componente oscuro aparte, cambias el valor de los tokens de color (el "foco" del sistema) y los mismos componentes se ven oscuros. Es exactamente la obra de teatro con dos iluminaciones del módulo 2, ahora expresada en Tailwind.

Y hay una tercera pieza pequeña pero importante: quién decide encender la luz de noche. Puede ser automático —una lámpara con sensor que se prende sola al atardecer— o manual —un interruptor que tú accionas cuando quieres—. En dark mode, "automático" es la estrategia media (sigue el modo oscuro del sistema operativo, vía prefers-color-scheme) y "manual" es la estrategia class (un toggle en la página que agrega o quita .dark). Las dos son válidas; eliges según si quieres que el usuario controle el tema o que solo siga a su sistema.

Guarda las tres imágenes: responsive = agregar cuartos a la casa base cuando hay terreno (los prefijos suman hacia arriba); dark por tokens = cambiar el foco de la lámpara, no re-decorar; estrategia media/class = sensor automático vs interruptor manual. Todo el módulo desarrolla esas tres ideas.

Ejemplo trabajado: una sola cadena, dos contextos

El corazón del módulo es una función: dada una lista de clases con prefijos y un contexto (viewport, theme), decide cuáles quedan activas. Este teaser la muestra con lo mínimo. Definimos la class="" del product-card —una sola cadena— y le preguntamos qué queda activo en dos contextos opuestos: un teléfono en modo claro y un desktop en modo oscuro. Fíjate en que no hay dos componentes: hay una cadena y dos preguntas.

// L1 intro — teaser: una sola cadena de clases responde a tamano Y a tema.
// resolveClasses es un MODELO PEDAGOGICO del cascade de prefijos de Tailwind (no es Tailwind).
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; // por defecto, cada utilidad es su propia "propiedad" (no choca con nadie)
}
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 product-card de Mercado: UNA sola cadena de clases, sin duplicar el componente.
const card = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 p-4 bg-white dark:bg-gray-900';

console.log('=== la misma class="" en dos contextos distintos ===\n');
console.log('movil + claro (360px, light):');
console.log('  ' + resolveClasses(card, { viewport: 360, theme: 'light' }).join(' '));
console.log('\ndesktop + oscuro (1200px, dark):');
console.log('  ' + resolveClasses(card, { viewport: 1200, theme: 'dark' }).join(' '));

Declaremos qué es y qué no es esto: resolveClasses es un modelo pedagógico del cascade de prefijos de Tailwind. Tailwind y el navegador no corren en un agente, así que el JSX y el tailwind.config reales se muestran en los bloques (es lo que escribes) y aquí ejecutamos la lógica que decide qué clase queda activa para un contexto —lo que el navegador hace por debajo con media queries y la clase .dark—.

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

=== la misma class="" en dos contextos distintos ===

movil + claro (360px, light):
  grid grid-cols-1 p-4 bg-white

desktop + oscuro (1200px, dark):
  grid lg:grid-cols-4 p-4 dark:bg-gray-900

Lee las dos salidas como el resumen del módulo. La cadena de entrada fue una solagrid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 p-4 bg-white dark:bg-gray-900— y produjo dos resultados distintos según el contexto. En el teléfono en modo claro (360px, light), quedaron activas grid-cols-1 (una columna, porque los prefijos md: y lg: piden más ancho del que hay) y bg-white (fondo blanco, porque dark: no aplica en tema claro). En el desktop en modo oscuro (1200px, dark), quedaron lg:grid-cols-4 (cuatro columnas, porque a 1200px el ancho supera el breakpoint lg) y dark:bg-gray-900 (fondo oscuro, porque ahora el tema es dark). Las clases que no dependen del contexto —grid, p-4— aparecen en las dos. Una cadena, dos contextos, dos apariencias, y cero duplicación: no escribiste una tarjeta para móvil y otra para desktop, ni una clara y otra oscura.

Fíjate en el mecanismo que esto revela y que el módulo entero desarrolla. Los prefijos no son clases distintas que "compiten": son la misma propiedad (el número de columnas) resuelta según cuánto ancho hay. La versión sin prefijo (grid-cols-1) es la base —la que aplica siempre, empezando por el móvil—; cada prefijo la reemplaza hacia arriba cuando su breakpoint se cumple. Eso es mobile-first, y es lo que hace que agregar md:grid-cols-2 sea "agregar un cuarto cuando hay terreno", no "rehacer la casa".

Una pregunta para cargar por el resto del módulo: en la segunda salida, ¿por qué dark:bg-gray-900 en vez de que bg-white cambiara de valor solo? (Aquí usamos la variante dark: explícita —una clase distinta para el tema oscuro—. Pero hay otra forma, más de sistema: que bg-surface apunte a un token que cambia de valor bajo .dark, sin escribir un dark: por elemento. Adelanto: es el theming por tokens del módulo 2, y lo construyes en la lección 6.)

El mapa del módulo

Guarda esta ruta; es cómo cada lección construye una parte de la capa responsive + dark:

Idea                                        Leccion   Concepto clave
──────────────────────────────────────────  ────────  ─────────────────────────────────────
Prefijos responsive, mobile-first           L2        base = movil; sm:/md:/lg: agregan
                                                      estilos hacia arriba (min-width)
Los breakpoints como escala del sistema     L3        sm 640 / md 768 / lg 1024 / xl 1280;
                                                      un set compartido, no valores sueltos
La variante dark:                           L4        dark:bg-gray-900: otra utilidad cuando
                                                      el tema es oscuro
media vs class (dos estrategias)            L5        prefers-color-scheme (auto) vs .dark en
                                                      <html> (toggle manual)
Theming por tokens en dark                  L6        bg-surface cambia de VALOR bajo .dark;
                                                      sin dark: por elemento (modulo 2)
Responsive + dark sin duplicar              L7        una class="" cubre N anchos x 2 temas;
                                                      un componente, no cuatro
──────────────────────────────────────────  ────────  ─────────────────────────────────────
Proyecto: el catalogo de Mercado            L8        product-card responsive (1->N columnas)
                                                      + dark por toggle .dark + tokens

La frontera: qué NO entra en este módulo

Saber la frontera te evita mezclar lo que otras lecciones y guías cubren:

  • El CSS responsive a mano —el viewport y su <meta>, las media queries @media (min-width: ...), las unidades fluidas (rem, vw, clamp), las imágenes responsive— es web-fundamentals-html-css, módulo 7. Aquí asumimos que sabes qué es una media query y qué es mobile-first; lo que hacemos es ver cómo Tailwind lo expresa con prefijos. Si "min-width" o "mobile-first" te suenan nuevos, ese es el módulo que falta, no este.
  • La mecánica de los tokens y el theming —primitivos vs semánticos vs de componente, resolveToken, el override por tema— es el módulo 2 de esta guía. Aquí lo reusamos para el dark por tokens (lección 6); no lo re-enseñamos.
  • Las variantes de componente por props (variant, size, el patrón cva) son el módulo 5. Las variantes de breakpoint (md:) y de tema (dark:) de este módulo son otra cosa: no las eliges al montar el componente, las resuelve el contexto. Se usan juntas, no se pisan.
  • Las primitivas accesibles (shadcn/Radix) son el módulo 7. Aquí no.
  • El bundle de CSS y el purgado —cómo Tailwind elimina las clases no usadas para que la hoja final sea pequeña— es fullstack-performance-and-deployment. Aquí lo mencionamos y remitimos.

Errores comunes

Creer que responsive y dark se hacen duplicando el componente. Qué pasa: para el móvil se crea un ProductCardMobile, para el desktop un ProductCardDesktop; para el tema oscuro, un ProductCardDark. Por qué pasa: "necesito que se vea distinto → hago otro componente" es lo más literal. Cómo detectarlo: tienes dos o cuatro componentes que solo difieren en unas clases de layout o color, y cada cambio de forma lo tienes que hacer en todos. Cómo corregirlo: un componente con una class="" que lleva prefijos (md:, dark:) y tokens; el contexto decide cuál aplica. Duplicar el componente por tamaño o por tema multiplica el mantenimiento —es el error que este módulo entero existe para eliminar—.

Diseñar desktop-first y pelear la cascada con max-*. Qué pasa: se escribe el layout de desktop como base y luego se "arregla" para móvil con prefijos de máximo ancho (max-md:). Por qué pasa: uno diseña mirando su monitor grande. Cómo detectarlo: tu class="" está llena de overrides max-* que deshacen la base, y el móvil se siente como una excepción remendada. Cómo corregirlo: mobile-first —la base es el móvil, los prefijos sm:/md:/lg: agregan hacia arriba—. La lección 2 mide por qué la cascada de min-width se lee mejor que la de max-width; el fondo está en web-fundamentals-html-css módulo 7.

Esperar re-aprender el CSS responsive aquí. Qué pasa: alguien abre el módulo esperando entender el viewport, las media queries o clamp. Por qué pasa: "responsive" suena a que aquí se enseña desde cero. Cómo detectarlo: te trabas en qué es una media query, no en cómo el prefijo md: la genera. Cómo corregirlo: eso es web-fundamentals-html-css módulo 7, el prerequisito. Aquí lo damos por sabido y nos concentramos en cómo el sistema lo expresa.

Ejercicios

Ejercicio 1 — ¿Prefijo o componente nuevo? Para cada situación, di si la resuelve un prefijo/token en la misma class="" (el enfoque del módulo) o si describe una duplicación de componente (lo que el módulo evita):

  • (a) El catálogo muestra 1 columna en móvil y 4 en desktop.
  • (b) Existe un ProductCardDark.jsx con los colores oscuros escritos, aparte del ProductCard.jsx claro.
  • (c) El fondo de la tarjeta es blanco de día y casi negro de noche, con bg-surface apuntando a un token que cambia de valor.
  • (d) Hay un Navbar para móvil y un NavbarDesktop completamente separados.
Ver solución
  • (a) Prefijo. Una sola clase de grid con prefijos: grid-cols-1 md:grid-cols-4. El número de columnas es la misma propiedad resuelta por ancho; no hay dos catálogos.
  • (b) Duplicación. Dos componentes que solo difieren en color, y cada cambio de forma hay que hacerlo en los dos. Es justo lo que el theming por tokens (lección 6) reemplaza: un ProductCard con bg-surface, y el token cambia bajo .dark.
  • (c) Token. El enfoque de sistema: la utilidad no cambia (bg-surface), cambia el valor del token por tema. Ni siquiera necesita un dark: por elemento.
  • (d) Duplicación. Dos navbars separados. A veces el layout móvil y desktop difieren tanto que se justifica más lógica, pero "completamente separados" suele ser el olor de que faltó resolver con prefijos y composición.

La pregunta guía: ¿la variación entra por un prefijo/token en la misma cadena, o por duplicar la pieza? Lo primero es sistema; lo segundo es la estantería que crece.

Ejercicio 2 — Predice el teaser. Con el resolveClasses del ejemplo trabajado y la cadena card, sin correr nada, di qué clases quedan activas para un tablet en modo claro: { viewport: 768, theme: 'light' }.

Ver solución

Quedan activas: grid grid-cols-... p-4 bg-white, con el grid resuelto a md:grid-cols-2. A 768px, el breakpoint md (768) se cumple (768 >= 768) pero lg (1024) no, así que la propiedad grid-cols la gana md:grid-cols-2, reemplazando a la base grid-cols-1. El tema es claro, así que bg-white gana y dark:bg-gray-900 queda fuera. grid y p-4 no dependen del contexto y siempre están. Resultado: grid md:grid-cols-2 p-4 bg-white. La lección: cada propiedad se resuelve por separado —el ancho decide el grid, el tema decide el fondo— y una sola cadena cubre las dos dimensiones a la vez.

Ejercicio 3 — Ubica la afirmación. Para cada afirmación sobre este módulo 6, di si es verdadera o falsa y por qué:

  • (a) "Los prefijos responsive de Tailwind son azúcar sobre las media queries que aprendiste en web-fundamentals."
  • (b) "Para tener dark mode hay que escribir cada componente dos veces."
  • (c) "Las variantes variant/size del módulo 5 se reemplazan por los prefijos md:/dark: de este módulo."
Ver solución
  • (a) Verdadera. md:p-4 genera, por debajo, una regla dentro de @media (min-width: 768px). El prefijo es la forma de Tailwind de escribir la media query que en web-fundamentals módulo 7 escribías a mano; el mecanismo del navegador es el mismo.
  • (b) Falsa. Todo el módulo existe para no duplicar: la variante dark: (o mejor, el token que cambia de valor) hace que un componente cubra los dos temas. Escribir cada componente dos veces es exactamente el error que se evita.
  • (c) Falsa. No se reemplazan: se complementan. variant/size son variantes de apariencia que tú eliges por props al montar el componente; md:/dark: son variantes de contexto que resuelve el ancho y el tema. Un Button puede tener las dos a la vez —variant="primary" y además class="text-base md:text-lg"—.

Resumen y siguiente paso

En esta lección instalaste la tesis del módulo 6: un componente responde al tamaño de la pantalla y al tema por prefijos y tokens en la misma class="", sin duplicarse. Con la casa que se amplía y la lámpara que cambia de foco viste las dos ideas que estructuran el módulo: mobile-first es agregar cuartos a la casa base cuando hay terreno (los prefijos suman hacia arriba), y el dark por tokens es cambiar el foco de la lámpara, no re-decorar la casa. Y lo comprobaste ejecutando: una sola cadena de clases del product-card resolvió dos apariencias opuestas —una columna y fondo blanco en el móvil claro, cuatro columnas y fondo oscuro en el desktop dark— sin un solo componente duplicado.

Antes de avanzar deberías poder: explicar por qué responsive y dark se hacen con prefijos/tokens y no duplicando el componente; nombrar las dos dimensiones que este módulo agrega (tamaño y tema) y cómo se distinguen de las variantes por props del módulo 5; y ubicar el CSS responsive a mano como el prerequisito de web-fundamentals módulo 7.

La lección 2 baja al primer mecanismo concreto: los prefijos responsive y el enfoque mobile-first. Antes de mezclar tamaño y tema hay que ver bien cómo p-2 md:p-4 lg:p-6 construye una cascada de min-width —la base móvil y los prefijos que agregan hacia arriba— y por qué eso es mobile-first y no al revés. Es el cimiento sobre el que el resto del módulo se apoya.

Recursos