Módulo 6: Responsive And Dark Mode
Responsive y dark sin duplicación: una sola `class=""` cubre todo
Descripción
Las lecciones anteriores construyeron dos dimensiones por separado: lo responsive —prefijos de ancho, grid-cols-1 md:grid-cols-2 lg:grid-cols-4 (lecciones 2-3)— y lo dark —color por tokens que cambian bajo .dark (lección 6)—. Esta lección las combina en una sola class="" y muestra el resultado que da nombre al módulo: un componente, sin duplicar, cubre todas las combinaciones de tamaño y tema a la vez. El product-card de Mercado lleva su grid responsive por prefijos y su color por tokens en la misma cadena, y esa cadena resuelve correctamente para el teléfono claro, el tablet oscuro, el desktop claro y las demás combinaciones —seis en total (tres anchos × dos temas)— con cero componentes duplicados.
La idea central es que las dos dimensiones son independientes y componibles. El ancho decide el layout (cuántas columnas, cuánto gap) sin tocar el color; el tema decide el color (vía tokens) sin tocar el layout. Como no se pisan, viven juntas en un atributo: cada propiedad se resuelve por su eje —el grid por el ancho, el fondo por el tema— y el elemento entero se adapta a las dos condiciones simultáneamente. No hay un ProductCardMobile, ni un ProductCardDark, ni las cuatro combinaciones cruzadas: hay un ProductCard, y el contexto lo resuelve.
Conexión con el módulo. Esta lección es la síntesis del módulo 6. Junta lo responsive (lecciones 2-3), la variante y estrategia de dark (lecciones 4-5) y el theming por tokens (lección 6) en un solo componente. Es también el ensayo del proyecto (lección 8), donde harás esto tú con el catálogo entero. Se apoya en todo lo anterior de la guía: los tokens (módulo 2), las utilidades (módulo 3), las escalas (módulo 4) y los componentes con variantes (módulo 5) que ahora, además, responden a tamaño y tema. La matriz completa —anchos × temas— se ejecuta en Node; el JSX real se muestra.
Una analogía: la casa que se amplía y cuya luz cambia, al mismo tiempo
Ya tienes las dos imágenes del módulo. La casa que se amplía (responsive): construyes la base móvil y agregas cuartos cuando hay terreno —los prefijos suman hacia arriba—. Y el regulador de luz (dark por tokens): giras el control central y toda la casa cambia de foco, sin re-decorar.
Lo que esta lección observa es que esas dos cosas son independientes y ocurren a la vez sobre la misma casa. El tamaño de la casa —cuántos cuartos— no depende de si es de día o de noche: una casa de tres cuartos tiene tres cuartos con luz de día y tres con luz de noche. Y el color de la luz no depende del tamaño: el regulador en "noche" pinta de cálido tanto la casa chica como la ampliada. Puedes cambiar el tamaño (agregar un cuarto) sin tocar la luz, y cambiar la luz (girar el regulador) sin tocar el tamaño. Son dos controles separados sobre la misma construcción.
Por eso una sola class="" cubre las seis combinaciones. El eje del tamaño (la casa base + los cuartos que se agregan) y el eje del tema (el regulador de luz) son perpendiculares: el ancho mueve uno, el tema mueve el otro, y ninguno interfiere con el otro. Tres tamaños de casa × dos colores de luz = seis estados, y los seis salen de una construcción con dos controles —no de seis casas distintas—. La moraleja del módulo entero: no duplicas el componente por tamaño ni por tema, porque tamaño y tema son controles sobre el mismo componente, no componentes aparte.
El mecanismo: dos ejes perpendiculares en una cadena
El product-card combina las dos técnicas que ya dominas, cada una en su eje:
<!-- el product-card: layout responsive por prefijos + color por tokens. UNA cadena. -->
<article class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4
gap-2 md:gap-4 p-4 rounded-md
bg-surface text-foreground">
...
</article>
Léela separando los dos ejes. El layout usa prefijos de ancho: grid-cols-1 md:grid-cols-2 lg:grid-cols-4 (columnas por breakpoint) y gap-2 md:gap-4 (más separación cuando hay ancho). El color usa tokens sin dark:: bg-surface text-foreground (fondo y texto que cambian de valor bajo .dark). Y hay clases que no dependen de ningún eje —grid, p-4, rounded-md—: constantes en todas las combinaciones. Cada propiedad se resuelve por su eje, y los ejes no se cruzan: el ancho nunca cambia el color, el tema nunca cambia el layout.
Fíjate en la decisión de diseño detrás de esto, que conecta con la lección 6: el color va por tokens (bg-surface), no por dark: explícito. Si hubiéramos usado bg-white dark:bg-gray-900, la cadena tendría el par de tema cruzado con los prefijos de ancho, y en casos raros (un color que además cambia por breakpoint) la interacción de dark: con md: se vuelve sutil. Al enrutar el color por tokens, el eje del tema sale por completo de la cadena de clases —vive en el override de --color-surface bajo .dark— y la cadena solo carga el eje responsive. Los dos ejes quedan limpiamente separados: prefijos para el tamaño, tokens para el tema. Es la combinación más mantenible, y la que el proyecto usa.
Ejemplo trabajado: la matriz de seis combinaciones
Vamos a medir que una sola cadena cubre las seis combinaciones. Corremos resolveClasses (para el layout) y resolveToken (para el color) sobre la cadena del product-card, para los tres anchos (360/768/1200) × dos temas (light/dark). Para cada combinación mostramos qué clase de grid queda activa y a qué color resuelven los tokens de fondo y texto:
// L7 — responsive + dark sin duplicar: una class="" cubre 6 combinaciones.
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';
if (base.startsWith('rounded')) return 'border-radius';
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);
}
// tokens + resolveToken del modulo 2 (para el color final por tema).
const tokens = {
primitives: { 'gray.50': '#f9fafb', 'gray.900': '#111827', 'white': '#ffffff' },
semantics: { 'color.surface': { light: 'white', dark: 'gray.900' }, 'color.text': { light: 'gray.900', dark: 'gray.50' } },
};
function resolveToken(name, theme) {
if (name in tokens.semantics) return resolveToken(tokens.semantics[name][theme], theme);
if (name in tokens.primitives) return tokens.primitives[name];
throw new Error('Token desconocido: ' + name);
}
// el product-card de Mercado: UNA cadena. Layout responsive por prefijos; color por tokens.
const card = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2 md:gap-4 p-4 rounded-md bg-surface text-foreground';
console.log('=== una sola class="" cubre 3 anchos x 2 temas = 6 combinaciones ===\n');
for (const viewport of [360, 768, 1200]) {
for (const theme of ['light', 'dark']) {
const active = resolveClasses(card, { viewport, theme });
const cols = active.find(c => c.includes('grid-cols'));
const surface = resolveToken('color.surface', theme);
const fg = resolveToken('color.text', theme);
console.log(`${String(viewport).padStart(4)}px ${theme.padEnd(5)} -> ${cols.padEnd(16)} bg-surface=${surface} text-foreground=${fg}`);
}
}
console.log('\nclases activas completas @ 1200px, dark:');
console.log(' ' + resolveClasses(card, { viewport: 1200, theme: 'dark' }).join(' '));
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== una sola class="" cubre 3 anchos x 2 temas = 6 combinaciones ===
360px light -> grid-cols-1 bg-surface=#ffffff text-foreground=#111827
360px dark -> grid-cols-1 bg-surface=#111827 text-foreground=#f9fafb
768px light -> md:grid-cols-2 bg-surface=#ffffff text-foreground=#111827
768px dark -> md:grid-cols-2 bg-surface=#111827 text-foreground=#f9fafb
1200px light -> lg:grid-cols-4 bg-surface=#ffffff text-foreground=#111827
1200px dark -> lg:grid-cols-4 bg-surface=#111827 text-foreground=#f9fafb
clases activas completas @ 1200px, dark:
grid lg:grid-cols-4 md:gap-4 p-4 rounded-md bg-surface text-foreground
Lee la matriz como los dos ejes actuando a la vez. Recorre las columnas hacia abajo (fijando el tema) y ves el eje responsive: en light, el grid va de grid-cols-1 (360) a md:grid-cols-2 (768) a lg:grid-cols-4 (1200) —el layout crece con el ancho, sin importar el tema—. Recorre las filas de a pares (fijando el ancho) y ves el eje del tema: a 360px, el fondo va de #ffffff (light) a #111827 (dark) mientras el grid se queda en grid-cols-1 —el color cambia con el tema, sin importar el ancho—. Los dos ejes son perpendiculares: el grid solo depende del ancho, el color solo del tema. Y las seis filas salieron de una sola cadena —no escribiste seis tarjetas—.
Fíjate en la prueba de la independencia. La clase de grid es idéntica entre light y dark a cada ancho (grid-cols-1 en las dos filas de 360, md:grid-cols-2 en las dos de 768, lg:grid-cols-4 en las dos de 1200): el tema no tocó el layout. Y los colores son idénticos entre los tres anchos a cada tema (#ffffff/#111827 en las tres filas light, #111827/#f9fafb en las tres dark): el ancho no tocó el color. Cada eje mueve solo lo suyo. Por eso una cadena basta: no hay seis combinaciones que escribir, hay dos controles independientes cuyo producto son seis estados.
Mira la última línea —las clases activas completas a 1200px en dark—: grid lg:grid-cols-4 md:gap-4 p-4 rounded-md bg-surface text-foreground. Ahí conviven las tres clases de tipos: las responsive resueltas (lg:grid-cols-4, md:gap-4 —las que ganaron su eje a ese ancho—), las de color por token (bg-surface, text-foreground —constantes en la cadena, su valor lo puso el tema—) y las constantes (grid, p-4, rounded-md —sin eje—). Una cadena, tres tipos de clase, seis estados. Ese es el componente responsive + dark sin duplicar.
Una pregunta para razonar el diseño: en la cadena, ¿por qué el color va por tokens (bg-surface) y no por dark: explícito (bg-white dark:bg-gray-900)? (Para mantener los ejes separados. Con tokens, el eje del tema sale por completo de la cadena de clases —vive en el override de --color-surface—, y la cadena solo carga el eje responsive. Con dark: explícito, los dos ejes conviven en la misma cadena, y si algún día un color además cambiara por breakpoint, tendrías que apilar md:dark:... con una interacción de especificidad más difícil de razonar. Enrutar el color por tokens deja cada eje en su lugar —prefijos para el tamaño, tokens para el tema— y es lo que hace que la matriz sea tan limpia.)
Profundización: cuándo los ejes se cruzan (y cómo mantenerlos limpios)
La independencia de los ejes es el caso sano y el que debes buscar, pero conviene saber cuándo no se cumple, para no toparte con ello sin aviso. Los ejes se cruzan cuando una misma propiedad debe cambiar por ancho y por tema a la vez —por ejemplo, un fondo que en móvil es de un color, en desktop de otro, y además todo eso se oscurece en dark—. Ahí tendrías que apilar prefijos: bg-a md:bg-b dark:bg-c md:dark:bg-d, cuatro variantes para una propiedad. Es posible en Tailwind, pero la interacción entre la media query (md:) y el selector de tema (dark:, que con la estrategia class es una regla .dark ...) se vuelve sutil de razonar.
La forma de evitar ese nudo es la que el proyecto usa: enruta el color por tokens y deja los prefijos para el layout. Si el fondo debe cambiar por ancho y por tema, casi siempre es señal de que el color debería ser un token (que resuelve el tema) y el cambio por ancho debería replantearse —¿de verdad el móvil necesita otro color de fondo, o eso era un layout distinto?—. En la enorme mayoría de los casos, el tema es puro color (→ tokens) y el ancho es puro layout (→ prefijos), y los dos ejes no comparten propiedad. Cuando los separas así, nunca apilas md:dark: y la cadena se lee de un vistazo.
Por eso el modelo resolveClasses que ejecutaste está pensado para el caso común —cada propiedad se controla por breakpoint o por tema, no por ambos a la vez—. Es una simplificación honesta: refleja cómo se escribe un sistema de diseño bien organizado, donde color y layout viven en ejes distintos. El apilamiento md:dark: existe en Tailwind y a veces se usa, pero su interacción de especificidad queda fuera de este modelo pedagógico —y, si diseñas con tokens para el color, fuera de tu código también—.
Errores comunes
Duplicar el componente para móvil y para dark en vez de combinar ejes. Qué pasa: se crea un ProductCardMobile (layout de una columna), un ProductCardDesktop (cuatro columnas) y encima un ProductCardDark de cada uno —cuatro componentes para lo que es un card—. Por qué pasa: cada combinación "se ve distinta", así que parece que necesita su propio componente. Cómo detectarlo: tienes un componente por cruce de tamaño × tema, y un cambio a la forma del card hay que hacerlo en los cuatro. Cómo corregirlo: un ProductCard con una class="" que lleva los prefijos de ancho (el eje responsive) y los tokens de color (el eje del tema). Las seis combinaciones son el producto de dos controles, no seis componentes. Duplicar por combinación multiplica el mantenimiento por el número de estados; combinar ejes lo deja en uno.
Cruzar color y layout en la misma propiedad sin necesidad. Qué pasa: se escribe un fondo que cambia por breakpoint y por tema con dark: explícito (bg-white md:bg-gray-100 dark:bg-gray-900 md:dark:bg-gray-800), enredando los dos ejes en una propiedad. Por qué pasa: se empieza con dark: explícito (lección 4) y luego se agrega el cambio responsive sin replantear. Cómo detectarlo: una propiedad de color tiene cuatro variantes apiladas (md:dark:...) y cuesta razonar cuál gana en cada combinación. Cómo corregirlo: separa los ejes —enruta el color por token (que resuelve el tema) y pregúntate si el cambio por ancho de ese color de verdad hace falta—. Casi siempre, el tema es color (→ token) y el ancho es layout (→ prefijo), y no comparten propiedad. Los ejes limpios se leen de un vistazo; los cruzados, no.
Verificar solo una esquina de la matriz. Qué pasa: se prueba el componente en desktop claro (donde se diseñó) y se asume que las otras cinco combinaciones "también estarán bien". Por qué pasa: uno diseña en su monitor grande y en tema claro, y esa es la única combinación que ve seguido. Cómo detectarlo: en móvil el layout se rompe, o en oscuro el contraste falla —bugs que solo aparecen en las esquinas no probadas—. Cómo corregirlo: recorre la matriz completa —los anchos por los temas— como en el ejemplo trabajado. En particular, el contraste hay que medirlo en cada tema (módulo 4): un par que pasa AA en claro puede fallar en oscuro. Ejecutar la matriz, como hiciste aquí, es exactamente la disciplina que atrapa esos bugs antes de que lleguen al usuario.
Ejercicios
Ejercicio 1 — Lee la matriz. Con la salida del ejemplo trabajado, sin correr nada, responde:
- (a) ¿Cuántas columnas y qué color de fondo tiene el card a 768px en tema oscuro?
- (b) ¿El grid a 1200px es el mismo en claro y en oscuro? ¿Por qué?
- (c) ¿El color de fondo a 360px es el mismo que a 1200px, fijando el tema en claro? ¿Por qué?
Ver solución
- (a) Dos columnas (
md:grid-cols-2) y fondo#111827(casi negro). A 768px se cumplemdpero nolg, así que dos columnas; en dark,color.surfaceresuelve agray.900=#111827. - (b) Sí, idéntico (
lg:grid-cols-4en las dos filas de 1200). El layout depende solo del ancho, no del tema: a 1200px hay cuatro columnas sea claro u oscuro. El tema no toca el grid. - (c) Sí, idéntico (
#ffffffen las tres filas light). El color depende solo del tema, no del ancho: en claro el fondo es blanco a cualquier ancho. El ancho no toca el color.
Las tres respuestas son la misma lección desde tres ángulos: los ejes son perpendiculares. El ancho mueve el layout y solo el layout; el tema mueve el color y solo el color.
Ejercicio 2 — Separa los ejes. Alguien escribió el fondo del card cruzando color y layout con dark: explícito:
bg-white md:bg-white lg:bg-white dark:bg-gray-900 md:dark:bg-gray-900 lg:dark:bg-gray-900
El fondo no cambia por ancho (es blanco en todos los breakpoints), solo por tema. Simplifícalo enrutando el color por token.
Ver solución
bg-surface
El fondo no depende del ancho —md:bg-white, lg:bg-white repiten el mismo blanco—, así que todos los prefijos de ancho sobran. Y no depende de un dark: en el marcado si va por token —bg-surface resuelve a blanco en claro y a #111827 en oscuro vía el override de --color-surface—. Los seis fragmentos colapsan a una utilidad. La lección: cuando una propiedad solo cambia por tema, va por token sin prefijos ni dark:; los prefijos de ancho eran ruido (el color no cambiaba por ancho) y el dark: se mudó al token. Un eje (tema) en su lugar, el otro (ancho) ausente porque esta propiedad no lo usa.
Ejercicio 3 — Diseña la cadena. Un Banner promocional de Mercado necesita: una columna en móvil y dos en desktop (a partir de lg); padding chico en móvil y grande a partir de md; fondo y texto que sigan el tema; y bordes redondeados iguales en todo. Escribe su class="" combinando prefijos (layout) y tokens (color), sin dark: explícito.
Ver solución
<div class="grid grid-cols-1 lg:grid-cols-2 p-3 md:p-6 rounded-lg bg-surface text-foreground">...</div>
Desglose por eje:
- Layout (prefijos de ancho):
grid-cols-1 lg:grid-cols-2(una columna, dos a partir delg);p-3 md:p-6(padding chico, grande a partir demd). - Color (tokens, sin
dark:):bg-surface text-foreground(fondo y texto que el token resuelve por tema). - Constante (sin eje):
grid,rounded-lg(iguales en toda combinación).
Fíjate en que los dos ejes no comparten propiedad: los prefijos gobiernan grid y padding; los tokens gobiernan los colores; nada se cruza. Una sola cadena, y el Banner responde a los cuatro tamaños × dos temas sin duplicarse. Ese es el patrón del módulo.
Resumen y siguiente paso
En esta lección viste la síntesis del módulo: una sola class="" cubre todas las combinaciones de tamaño y tema, porque los dos ejes son independientes —el ancho gobierna el layout (prefijos), el tema gobierna el color (tokens)— y no se pisan. Con la casa que se amplía y cuya luz cambia al mismo tiempo entendiste por qué: el tamaño y el tema son dos controles perpendiculares sobre la misma construcción, y su producto —seis estados— sale de una cadena con dos ejes, no de seis componentes. Y lo comprobaste ejecutando la matriz completa: el grid del product-card creció con el ancho idéntico en ambos temas, y el color siguió el tema idéntico en los tres anchos —cada eje moviendo solo lo suyo, las seis combinaciones desde una cadena—.
Antes de avanzar deberías poder: separar la class="" de un componente en su eje responsive (prefijos) y su eje de tema (tokens); explicar por qué enrutar el color por tokens mantiene los ejes limpios y evita apilar md:dark:; y recorrer la matriz de combinaciones para verificar, incluido el contraste por tema (módulo 4).
La lección 8 te pone a construirlo tú. Vas a tomar el catálogo de Mercado —la rejilla de product-cards— y hacerlo responsive (de 1 a N columnas con prefijos) y dark (toggle por .dark + tokens), sin duplicar el componente, y a verificar con el modelo Node qué clases y qué tokens quedan activos por cada combinación de breakpoint y tema. Es la matriz de esta lección, pero armada y comprobada por ti sobre el caso completo —el cierre del módulo—.
Recursos
- Tailwind CSS, "Responsive Design" — tailwindcss.com/docs/responsive-design. El eje responsive (prefijos de ancho) que se combina aquí con el eje del tema. En inglés.
- Tailwind CSS, "Dark Mode" — tailwindcss.com/docs/dark-mode. El eje del tema (variante
dark:y tokens) que se combina aquí con el eje responsive. En inglés. - Tailwind CSS, "Hover, Focus, and Other States" (sección "Stacking variants") — tailwindcss.com/docs/hover-focus-and-other-states. Cómo se apilan variantes (
md:dark:) cuando de verdad hace falta cruzar ejes; el caso que el diseño con tokens evita. En inglés. - web.dev, "prefers-color-scheme" — web.dev/articles/prefers-color-scheme. Buenas prácticas de dark mode que se sostienen al combinarlo con responsive; verificar cada combinación. En inglés.