Módulo 6: Responsive And Dark Mode

Theming por tokens en dark: la utilidad no cambia, el token sí

Descripción

En la lección 4 viste el costo de dark: explícito: cada elemento con color necesita su par escrito a mano (bg-white dark:bg-gray-900), y en una app con cien elementos escribes cien pares. Esta lección trae la forma de sistema, la que reusa los tokens del módulo 2: en vez de escribir el par por elemento, haces que la utilidad apunte a un tokenbg-surface— y que ese token cambie de valor bajo .dark. El elemento lleva solo bg-surface, sin dark:, y el tema lo resuelve el token. Esa es la idea central: la utilidad es la misma en los dos temas; lo que cambia es el valor al que el token resuelve.

Es exactamente el "cambiar el valor del token semántico, no el componente" del módulo 2, ahora expresado en Tailwind. Allá ejecutaste resolveToken('color.surface', theme) y viste que el mismo color.surface daba blanco en claro y casi negro en oscuro. Aquí conectas ese mecanismo con las utilidades: bg-surface es una utilidad de Tailwind cuyo color es var(--color-surface), y .dark redefine --color-surface. Cuando el toggle de la lección 5 agrega .dark, el token cambia de valor y bg-surface —sin tocar una sola clase del elemento— se vuelve oscuro. Un cambio de foco central; ninguna bombilla duplicada.

Conexión con el módulo. Esta lección cierra el dark mode del sistema tejiendo tres hilos: la variante dark: (lección 4), la estrategia class con su .dark (lección 5) y los tokens con override por tema (módulo 2). Es la culminación del "sin duplicar": ni el componente (variantes, módulo 5), ni el par de color por elemento (lección 4) —el tema entero vive en el override de tokens bajo una clase—. La lección 7 juntará esto con lo responsive para mostrar que una sola class="" cubre tamaño y tema a la vez. El resolveToken del módulo 2 se ejecuta aquí sin cambiarle una línea; el tailwind.config y el CSS de tokens se muestran.

Una analogía: el foco central que cambia toda la casa

Vuelve a las cien lámparas de la lección 4. Con dark: explícito, cada lámpara tenía dos bombillas —una clara y una oscura— que tú instalaste a mano; cambiar de tema encendía la bombilla correcta de cada una. Funciona, pero son doscientas bombillas que instalar y mantener, y el día que quieras cambiar el tono del oscuro, tienes que ir lámpara por lámpara reemplazando la bombilla oscura.

Ahora imagina otra instalación. En vez de dos bombillas por lámpara, cada lámpara tiene una sola y todas están conectadas a un regulador central —un solo control que define "qué luz emiten todas las lámparas de la casa"—. De día, el regulador está en "luz de día" y las cien lámparas emiten blanco. Giras el regulador a "luz de noche" y las cien, al instante, emiten cálido. No tocaste ninguna lámpara: cambiaste el foco central, y todas lo heredaron. Y si mañana quieres otro tono de noche, ajustas el regulador una vez —no cien bombillas—.

Ese regulador central es el token con override por tema. bg-surface es una lámpara conectada al regulador --color-surface; no tiene bombilla clara y bombilla oscura, tiene una conexión al regulador. La clase .dark es girar el regulador: redefine --color-surface de blanco a casi negro, y todas las utilidades bg-surface del sitio —el product-card, la SearchBar, el navbar— cambian a la vez, sin que ninguna lleve un dark:. La moraleja es la del módulo 2, ahora con nombre de Tailwind: para cambiar de tema no re-decoras cada cuarto ni pones dos bombillas por lámpara; giras el regulador central una vez.

El mecanismo: la utilidad apunta a un token, el token cambia bajo .dark

El puente entre tokens y utilidades se arma en el tailwind.config: mapeas los nombres de color de Tailwind a las custom properties de tus tokens.

// tailwind.config.js — las utilidades de color apuntan a tokens (esto se MUESTRA)
export default {
  darkMode: 'class', // la estrategia de la leccion 5: el toggle .dark
  theme: {
    extend: {
      colors: {
        primary:    'var(--color-primary)',
        surface:    'var(--color-surface)',
        foreground: 'var(--color-text)',
      },
    },
  },
};

Con esto, bg-surface ya no es un color fijo: es background-color: var(--color-surface). Y text-foreground es color: var(--color-text). La utilidad quedó conectada al regulador. Ahora defines los dos temas redefiniendo esas custom properties —el bloque .dark del módulo 2—:

/* los tokens, con su override por tema (esto se MUESTRA; es el modulo 2) */
:root {
  --color-surface: #ffffff;   /* tema claro: fondo blanco */
  --color-text:    #111827;   /* tema claro: texto casi negro */
}
.dark {
  --color-surface: #111827;   /* tema oscuro: fondo casi negro */
  --color-text:    #f9fafb;   /* tema oscuro: texto casi blanco */
}

Junta las dos piezas y mira lo que pasa en el elemento:

<!-- el product-card: SOLO bg-surface y text-foreground. Ni un dark:. (esto se MUESTRA) -->
<article class="bg-surface text-foreground p-4 rounded-md">
  <h3>Auriculares inalambricos</h3>
  <p>$1,299</p>
</article>

Fíjate en lo que no está en esa class="": no hay dark:bg-..., no hay dark:text-.... El elemento lleva bg-surface y text-foreground a secas. Cuando el toggle agrega .dark al <html>, --color-surface pasa de #ffffff a #111827 y --color-text de #111827 a #f9fafb; las utilidades, que apuntan a esas variables, cambian de color sin que el marcado cambie. Compáralo con la lección 4, donde el mismo card llevaba bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-50 —cuatro clases, dos pares—. Aquí son dos clases, cero pares. El tema se mudó del elemento al token.

Ejemplo trabajado: la misma utilidad, el token cambia de valor

Vamos a medir que la utilidad es la misma en los dos temas y que lo que cambia es el valor del token. Reusamos el resolveToken del módulo 2 —sin cambiarle una línea— y un pequeño mapa utilityToken que dice a qué token apunta cada utilidad. Resolvemos la cadena bg-surface text-foreground (sin un solo dark:) en los dos temas: resolveClasses muestra que las clases activas son idénticas, y resolveToken muestra que el color al que llegan cambia:

// L6 — theming por tokens en dark: una sola utilidad, el token cambia de valor.
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);
}

// tokens y resolveToken TAL CUAL el modulo 2, sin cambiar una linea.
const tokens = {
  primitives: { 'blue.600': '#2563eb', 'blue.400': '#60a5fa', 'gray.50': '#f9fafb', 'gray.900': '#111827', 'white': '#ffffff' },
  semantics: {
    'color.primary': { light: 'blue.600', dark: 'blue.400' },
    'color.surface': { light: 'white',    dark: 'gray.900' },
    'color.text':    { light: 'gray.900', dark: 'gray.50'  },
  },
  component: { 'button.bg': 'color.primary', 'card.bg': 'color.surface', 'card.text': 'color.text' },
};
function resolveToken(name, theme) {
  if (name in tokens.component)  return resolveToken(tokens.component[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);
}

// las utilidades del sistema mapean a tokens (via CSS vars en tailwind.config).
// NO llevan dark:; el token cambia de valor bajo .dark, y la utilidad lo hereda.
const utilityToken = { 'bg-surface': 'color.surface', 'text-foreground': 'color.text', 'bg-primary': 'color.primary' };
const cardClasses = 'bg-surface text-foreground'; // la MISMA cadena en los dos temas
console.log('=== una sola utilidad, el token cambia de valor por tema ===\n');
console.log('cadena: "' + cardClasses + '"  (sin un solo dark:)\n');
for (const theme of ['light', 'dark']) {
  const active = resolveClasses(cardClasses, { viewport: 1200, theme });
  const rendered = active.map(cls => `${cls}=${resolveToken(utilityToken[cls], theme)}`);
  console.log(`theme ${theme.padEnd(5)} -> clases: ${active.join(' ')}  ->  ${rendered.join('  ')}`);
}

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

=== una sola utilidad, el token cambia de valor por tema ===

cadena: "bg-surface text-foreground"  (sin un solo dark:)

theme light -> clases: bg-surface text-foreground  ->  bg-surface=#ffffff  text-foreground=#111827
theme dark  -> clases: bg-surface text-foreground  ->  bg-surface=#111827  text-foreground=#f9fafb

Lee las dos líneas comparando dos columnas: las clases activas y el color renderizado. Fíjate primero en las clases: en los dos temas son idénticasbg-surface text-foreground—. No hay un dark: que gane en oscuro; la cadena no tiene variantes de tema. Ahora fíjate en el color al que llegan: en claro, bg-surface resuelve a #ffffff (blanco) y text-foreground a #111827 (casi negro); en oscuro, bg-surface resuelve a #111827 (casi negro) y text-foreground a #f9fafb (casi blanco). La misma utilidad, el mismo bg-surface, dio dos colores según el tema —porque el token que hay detrás cambió de valor—. El fondo y el texto se invirtieron de forma coherente, igual que en el módulo 2, y sin escribir un solo par dark:.

Ese contraste entre las dos columnas es toda la lección. Con dark: explícito (lección 4), la clase cambiaba entre temas: bg-white en claro, dark:bg-gray-900 en oscuro —dos clases distintas, escritas a mano—. Con tokens, la clase no cambia (bg-surface siempre), y lo que cambia es el valor al que resuelve. Movimos la decisión del tema desde el marcado (donde había que escribir el par por elemento) hacia el token (donde se define una vez y todos lo heredan). El elemento se volvió sordo al tema —solo dice "quiero el fondo de superficie"— y el token, girado por .dark, decide qué color es eso.

Una pregunta para razonar el ahorro: si mañana el diseño pide que el fondo oscuro sea #0f172a en vez de #111827, ¿cuántos elementos tienes que tocar? (Cero elementos: cambias --color-surface en el bloque .dark una vez, y las cien utilidades bg-surface del sitio lo heredan. Con dark: explícito tendrías que encontrar y cambiar cada dark:bg-gray-900 disperso por el código —el drift del copy-paste que el módulo 2 combatía—. El token es el regulador central: un ajuste, toda la casa cambia.)

Profundización: cuándo dark: explícito sigue teniendo sentido

Que el token sea la forma de sistema no significa que dark: explícito desaparezca. Hay casos donde escribir el par por elemento es lo correcto. El más claro es cuando un elemento necesita un color oscuro que no corresponde a ningún rol semántico de tu sistema —un ajuste puntual, una excepción de una pantalla—. Ahí un dark: local es más honesto que inventar un token semántico que solo un elemento usa. La regla del módulo 2 aplica igual: los roles recurrentes van a tokens; los ajustes de una vez pueden ir inline.

Otro caso es cuando el cambio de tema afecta algo que no es un color de token —una opacidad, una sombra específica, un borde que solo en oscuro debe aparecer—. dark:opacity-80 o dark:border son variantes de tema que no tienen por qué pasar por un token de color. dark: sigue siendo la herramienta para las variaciones de tema que no son "el valor de un rol de color".

Pero el grueso del color de un sistema de diseño —fondos de superficie, texto, color de marca, bordes recurrentes— va por tokens, no por dark: por elemento. La proporción sana en un sistema maduro: casi todo el color sale de utilidades que apuntan a tokens (bg-surface, text-foreground, bg-primary), sin dark:; y un puñado de dark: explícitos para las excepciones. Si te descubres escribiendo dark: en cada elemento de color, esa es la señal de que faltan tokens —el mismo síntoma que en el módulo 2 indicaba que un color debía volverse un rol—.

Errores comunes

Hardcodear el color en el componente y no poder hacer dark con tokens. Qué pasa: el product-card se estiló con bg-white text-gray-900 (colores crudos, no tokens), y al llegar el dark mode no hay un token que redefinir —hay que volver a tocar cada elemento—. Por qué pasa: al construir en claro, el color crudo es lo más directo y "ya se ve bien". Cómo detectarlo: tu marcado está lleno de bg-white, text-gray-900, bg-blue-600 —valores, no roles— y agregar dark mode obliga a escribir un dark: en cada uno. Cómo corregirlo: enruta el color por tokens desde el principio (bg-surface, text-foreground, bg-primary), para que el dark mode sea redefinir el token, no reeditar el componente. Es la venganza de hardcodear color que ya viste en el módulo 2: nombrar por valor hace el theming caro; nombrar por rol lo hace un override. El dark mode con tokens empieza en cómo estilaste en claro.

Poner el override de tema en la capa equivocada. Qué pasa: se intenta hacer dark cambiando el valor de un primitivo (--gray-900 distinto en claro y oscuro) o poniendo dark: en el token de componente. Por qué pasa: no queda claro cuál de las capas del módulo 2 "sabe" del tema. Cómo detectarlo: el theming se siente disperso —algunos colores cambian y otros no, o cambian de forma contradictoria—. Cómo corregirlo: como en el módulo 2, solo la capa semántica tiene override por tema. Los primitivos son fijos (la caja de pigmentos), los de componente heredan, y el bloque .dark redefine los semánticos (--color-surface, --color-text). Si un primitivo cambia por tema, dejó de ser un valor crudo. El regulador vive en el medio, no arriba ni abajo.

Contraste que pasa en claro pero falla en oscuro. Qué pasa: se eligen los valores oscuros del token "a ojo" y el texto queda ilegible sobre el fondo oscuro —o el color.primary oscuro no contrasta con la superficie oscura—. Por qué pasa: se verifica el contraste en claro (donde se diseñó) y se asume que el oscuro heredará la accesibilidad. Cómo detectarlo: en modo oscuro, un par de colores se lee con esfuerzo o no cumple el mínimo. Cómo corregirlo: cada tema es una combinación distinta y necesita su propia verificación —el contrastRatio(fg, bg) del módulo 4, corrido para los valores oscuros del token—. color.text sobre color.surface debe pasar AA en claro y en oscuro, por separado. Un token con override no garantiza contraste solo por existir; el valor oscuro que le pusiste hay que medirlo. Remite al módulo 4 para el algoritmo.

Ejercicios

Ejercicio 1 — Resuelve utilidad y valor. Con el resolveToken y el utilityToken del ejemplo, sin correr nada, di la clase activa y el color renderizado de bg-primary en cada tema.

Ver solución

La clase activa es bg-primary en los dos temas —no hay dark:, la utilidad no cambia—. El color al que resuelve sí cambia, siguiendo bg-primary → color.primary:

themeclase activacolor renderizadocadena del token
lightbg-primary#2563ebcolor.primary → blue.600 → #2563eb
darkbg-primary#60a5facolor.primary → blue.400 → #60a5fa

Igual que bg-surface: la utilidad es constante, el token cambia de valor. El azul de marca es más intenso en claro (blue.600) y más suave en oscuro (blue.400), para no brillar demasiado sobre fondo oscuro —una decisión del token, no del elemento—.

Ejercicio 2 — De dark: explícito a tokens. Un componente trae este marcado de la lección 4, con pares dark: a mano. Reescríbelo usando tokens (bg-surface, text-foreground), asumiendo el tailwind.config del ejemplo, y di qué se gana.

<article class="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-50 p-4">...</article>
Ver solución
<article class="bg-surface text-foreground p-4">...</article>

Los dos pares dark: colapsan a dos utilidades sin variante: bg-white ... dark:bg-gray-900bg-surface, y text-gray-900 ... dark:text-gray-50text-foreground. Los colores oscuros ya no viven en el marcado; viven en el bloque .dark que redefine --color-surface y --color-text. Qué se gana: (1) el marcado es la mitad de largo y no repite el tema por elemento; (2) si cambia el valor oscuro, se toca el token una vez, no cada elemento; (3) todos los elementos que usan bg-surface cambian juntos y de forma coherente —imposible que uno se desincronice—. El tema se mudó del elemento al regulador central.

Ejercicio 3 — ¿Token o dark: explícito? Para cada necesidad, di si conviene enrutar por token (utilidad sin dark:) o usar un dark: explícito, y por qué:

  • (a) El fondo de todas las superficies (tarjetas, modales, navbar) cambia de blanco a casi negro.
  • (b) Una imagen decorativa de una sola pantalla debe bajar su opacidad al 80% solo en oscuro.
  • (c) El color de marca de los botones es más intenso en claro y más suave en oscuro.
  • (d) Un badge de "oferta" de una campaña puntual necesita un borde que solo aparece en modo oscuro.
Ver solución
  • (a) Token. "Todas las superficies" es un rol recurrente del sistema → color.surface con override por tema, consumido como bg-surface. Es justo lo que el token existe para resolver: un cambio, todas las superficies.
  • (b) dark: explícito. La opacidad no es un color de token, y es de una sola pantalla → dark:opacity-80 local. Inventar un token para una excepción de opacidad puntual sería sobre-ingeniería.
  • (c) Token. El color de marca es un rol central del sistema → color.primary con { light: 'blue.600', dark: 'blue.400' }, consumido como bg-primary. Todos los botones lo heredan.
  • (d) dark: explícito. Un ajuste puntual de una campaña, no un rol recurrente → dark:border local. Cuando la campaña termine, se borra sin tocar el sistema de tokens.

La regla del módulo 2, aplicada al dark: los roles recurrentes van a tokens; los ajustes de una vez pueden ir con dark: inline. Si escribes dark: en cada color, faltan tokens.

Resumen y siguiente paso

En esta lección viste la forma de sistema del dark mode: la utilidad apunta a un token (bg-surface), y el token cambia de valor bajo .dark; el elemento no lleva dark:. Con el foco central que cambia toda la casa entendiste el principio: en vez de dos bombillas por lámpara (el par dark: por elemento de la lección 4), una sola lámpara conectada a un regulador central (el token) que giras una vez —la clase .dark— y todas heredan. Y lo comprobaste ejecutando: la cadena bg-surface text-foreground fue idéntica en los dos temas, mientras resolveToken mostró que el color al que resuelve cambió —blanco/casi-negro en claro, casi-negro/casi-blanco en oscuro—. La clase no cambió; el token, sí.

Antes de avanzar deberías poder: mapear una utilidad de color a un token en tailwind.config; explicar por qué el bloque .dark que redefine tokens necesita la estrategia class (lección 5); decidir cuándo un color va por token y cuándo un dark: explícito basta; y recordar que cada tema necesita su propia verificación de contraste (módulo 4).

La lección 7 junta las dos dimensiones del módulo. Hasta ahora las viste por separado: lo responsive (prefijos de ancho, lecciones 2-3) y lo dark (variante y tokens, lecciones 4-6). Ahora las combinas en una sola class="": el product-card con su grid responsive por prefijos y su color por tokens, resuelto para todas las combinaciones de tamaño y tema a la vez. Vas a ejecutar la matriz completa —tres anchos por dos temas— y ver, medido, que una sola cadena cubre las seis combinaciones sin duplicar el componente. Es el cierre del sistema responsive + dark.

Recursos

  • Tailwind CSS, "Dark Mode" (sección "Customizing your theme with CSS variables") — tailwindcss.com/docs/dark-mode. Cómo hacer dark mode con tokens/custom properties en vez de pares dark: por elemento; el patrón de esta lección. En inglés.
  • Tailwind CSS, "Theme" (sección "Using CSS variables") — tailwindcss.com/docs/theme. Cómo mapear colores de Tailwind a var(--token) en el config, el puente entre utilidades y tokens. En inglés.
  • MDN, "Using CSS custom properties — Inheritance and the cascade" — developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties. Cómo la redefinición de una variable en .dark hace que todas las utilidades que la usan cambien a la vez; el mecanismo del regulador central. En inglés.
  • Material Design 3, "Color roles & themes" — m3.material.io/styles/color/roles. Cómo un sistema real define roles de color con override por tema; el fondo conceptual de los tokens semánticos. En inglés.