Módulo 3: Utility First With Tailwind

Configurar Tailwind con tus tokens

Descripción

En las lecciones anteriores, bg-primary apareció una y otra vez apuntando a var(--color-primary) como si fuera un hecho de la naturaleza. No lo es: es algo que tú configuras. Tailwind, por defecto, trae su propia paleta (bg-blue-600, bg-red-500…) que no sabe nada de tu marca. Para que exista una utilidad bg-primary que apunte a tu token del módulo 2, tienes que decirle a Tailwind, en su archivo de configuración, "cuando alguien escriba bg-primary, el color primary es var(--color-primary)". Eso se hace en theme.extend, y es el enganche que une las dos capas del sistema: los tokens que fabricaste en el módulo 2 abajo, las utilidades que los consumen arriba.

Esta lección muestra ese enganche. Vas a ver el bloque de configuración real —tailwind.config.js con theme.extend.colors— y, para entender qué produce, ejecutar un modelo que sigue la cadena completa: la utilidad bg-primary → el mapeo de la config (primaryvar(--color-primary)) → la declaración CSS (background-color: var(--color-primary)) → y, en tiempo de ejecución, el valor del token resuelto por tema (#2563eb en claro, #60a5fa en oscuro, con el resolveToken del módulo 2). Es la cadena tokens-utilidades entera, de punta a punta.

Conexión con el módulo. Esta lección paga la deuda que todas las anteriores dejaron: de dónde sale bg-primary. Cierra el círculo con el módulo 2 —resolveToken reaparece aquí, ahora al servicio de una utilidad— y prepara el proyecto (donde el product-card usará bg-surface y bg-primary conectados a tokens). Es la lección que hace del sistema un sistema: sin ella, las utilidades de color serían valores sueltos; con ella, heredan tu theming, tu escala y tu rebranding gratis. La lección 7 (@apply) y el proyecto se apoyan en que este enganche ya existe.

Una analogía: enseñarle a la fábrica tus colores

Vuelve a la fábrica de ladrillos de la lección 4, la que fabricó todo el catálogo por adelantado. Esa fábrica, de fábrica (valga la redundancia), trae sus propios colores: un rojo estándar, un azul estándar, los colores que el fabricante eligió. Si armas tu casa con esos, queda con los colores del fabricante, no con los tuyos.

Pero la fábrica tiene una ventana de configuración: puedes enseñarle tus colores antes de que imprima el catálogo. Le entregas tu paleta maestra —la que preparaste en el módulo 2, con sus botes etiquetados "primary", "surface", "text"— y le dices "agrega a tu catálogo ladrillos con estos colores, con estos nombres". La fábrica entonces fabrica, además de sus ladrillos estándar, un ladrillo bg-primary, uno bg-surface, uno text-text, cada uno pintado con el color de tu bote correspondiente. Ahora tu catálogo tiene ladrillos con tus colores y tus nombres de rol.

Y hay un detalle hermoso: no le entregas a la fábrica el pigmento (el hex), le entregas la etiqueta del bote (var(--color-primary)). Así, el ladrillo bg-primary no queda pintado con un azul fijo; queda pintado con "lo que sea que haya en el bote 'primary' cuando se use". Si mañana cambias el bote (tema oscuro, o un rebranding), el ladrillo bg-primary cambia de color solo —porque apunta al bote, no al pigmento—. Eso es lo que hace theme.extend: le enseña a Tailwind tus botes etiquetados, no tus pigmentos, para que las utilidades hereden el theming del módulo 2.

Nota de versión — Tailwind v4. Estas lecciones usan tailwind.config.js (la configuración en JavaScript de Tailwind v3). Desde Tailwind v4 (enero 2025) la config es CSS-first: los tokens se declaran con @theme { --color-primary: ...; } directamente en el CSS, sin theme.extend. El concepto —enseñarle a Tailwind tus tokens para que las utilidades hereden el theming— es idéntico en las dos versiones; solo cambia dónde se escribe. Si arrancas un proyecto v4 y prefieres seguir con este tailwind.config.js, cárgalo desde tu CSS con @config "../tailwind.config.js";.

Ejemplo trabajado: bg-primary → tu token → el valor por tema

Primero, el enganche que escribes —el archivo de configuración de Tailwind, que se muestra, no se ejecuta, porque es lo que el alumno pone en su proyecto—. En theme.extend.colors, mapeas cada nombre de rol a la referencia de tu token del módulo 2:

// tailwind.config.js — enseñarle a Tailwind tus tokens del modulo 2.
export default {
  content: ['./src/**/*.{html,jsx,tsx}'],
  theme: {
    extend: {
      colors: {
        // nombre de rol -> referencia al token (la CSS custom property del modulo 2)
        primary: 'var(--color-primary)',
        surface: 'var(--color-surface)',
        text:    'var(--color-text)',
      },
    },
  },
};

Con eso, Tailwind genera las utilidades bg-primary, text-primary, bg-surface, etc., cada una apuntando a la var() correspondiente —no a un hex—. El theme.extend suma a la paleta por defecto (por eso extend); si usaras theme.colors a secas, reemplazarías toda la paleta de Tailwind por la tuya.

Ahora, para ver qué produce ese mapeo, ejecutamos un modelo en Node. Tiene tres piezas: (1) el mapa de la config (themeColors), (2) una función que, con ese mapa, genera la declaración CSS de una utilidad de color, y (3) el resolveToken del módulo 2, para mostrar a qué valor resuelve la var() en cada tema:

// L6 — configurar Tailwind con TUS tokens: theme.extend mapea el nombre de la utilidad al token.
// (1) el mapa que iria en tailwind.config.js -> theme.extend.colors
const themeColors = {
  primary: 'var(--color-primary)',
  surface: 'var(--color-surface)',
  text:    'var(--color-text)',
};

// (2) con ese mapa, bg-<name> / text-<name> emiten una declaracion que APUNTA al token.
function utilWithTokens(cls) {
  let m;
  if ((m = cls.match(/^bg-([a-z]+)$/)))   return 'background-color: ' + themeColors[m[1]];
  if ((m = cls.match(/^text-([a-z]+)$/)))  return 'color: ' + themeColors[m[1]];
  throw new Error('utilidad de color fuera del subconjunto: ' + cls);
}

// (3) los tokens de M2 (resolveToken): que valor tiene la var() en cada tema.
const tokens = {
  primitives: { 'blue.600': '#2563eb', 'blue.400': '#60a5fa', 'gray.900': '#111827', 'gray.50': '#f9fafb', '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'  } },
};
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);
}

console.log('=== la utilidad no trae color: apunta a tu token ===\n');
for (const cls of ['bg-primary', 'bg-surface', 'text-text']) {
  const decl = utilWithTokens(cls);
  const role = 'color.' + cls.replace(/^(bg|text)-/, '');
  console.log('  .' + cls.padEnd(11) + '-> ' + decl.padEnd(40) +
              '-> ' + resolveToken(role, 'light') + ' (light) / ' + resolveToken(role, 'dark') + ' (dark)');
}

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

=== la utilidad no trae color: apunta a tu token ===

  .bg-primary -> background-color: var(--color-primary)  -> #2563eb (light) / #60a5fa (dark)
  .bg-surface -> background-color: var(--color-surface)  -> #ffffff (light) / #111827 (dark)
  .text-text  -> color: var(--color-text)                -> #111827 (light) / #f9fafb (dark)

Lee cada línea como la cadena de las dos capas, de punta a punta. Toma la primera: .bg-primary (la utilidad, capa 3 de este módulo) genera background-color: var(--color-primary) (la declaración, gracias al mapeo de la config), y esa var() resuelve —en tiempo de ejecución, por el token del módulo 2— a #2563eb en tema claro y #60a5fa en tema oscuro. La misma utilidad, bg-primary, da dos colores distintos según el tema, y tú no escribiste dos utilidades ni tocaste el marcado: el valor vive en el token, y el token cambia con el tema (módulo 2, lección 6).

Ese es el pago de configurar Tailwind con tus tokens en vez de con hex. Compara con la alternativa mala: si hubieras escrito bg-[#2563eb] (una utilidad arbitraria con el hex adentro), el botón sería ese azul siempre —en tema oscuro seguiría siendo #2563eb, un azul demasiado saturado para fondo oscuro, porque el valor está congelado en la clase—. Con bg-primary apuntando al token, el tema oscuro le da automáticamente #60a5fa, el azul más claro que el módulo 2 definió para superficies oscuras. La utilidad hereda todo el trabajo de theming que hiciste abajo.

Fíjate también en la línea de .text-text: la utilidad text-text (el color de texto usando el rol text) resuelve a #111827 (casi negro) en claro y #f9fafb (casi blanco) en oscuro. El texto se invierte con el tema, solo, porque text-text apunta a --color-text. El nombre text-text se ve raro —"texto-texto"—; es el precio de que el rol se llame text y la utilidad de color de texto también empiece con text. Muchos sistemas nombran el rol foreground para evitar el trabalenguas (text-foreground); es una decisión de nombres del módulo 2, no de este.

Una pregunta hacia el proyecto: si en la config agregaras muted: 'var(--color-muted)' (el rol de fondo sutil que definiste en el proyecto del módulo 2), ¿qué utilidad nueva tendrías disponible, y a qué apuntaría? (Tendrías bg-muted —y text-muted—, apuntando a var(--color-muted), que resuelve a #f3f4f6 en claro y #1f2937 en oscuro. Un rol nuevo en la config = utilidades nuevas gratis, sin escribir CSS. Es el mismo "un rol nuevo, cero valores nuevos" del módulo 2, ahora del lado de las utilidades.)

Profundización: extend vs reemplazar, y qué más se mapea

El detalle extend merece una pausa, porque confundirlo rompe cosas. Dentro de theme, Tailwind distingue dos formas de configurar:

  • theme.colors (sin extend) reemplaza toda la paleta por defecto. Si escribes theme: { colors: { primary: '...' } }, borras bg-blue-600, bg-gray-100 y todas las demás: solo te queda bg-primary. A veces es lo que quieres (un sistema cerrado que solo permite tus roles); a menudo no, porque pierdes utilidades útiles como los grises neutros.
  • theme.extend.colors suma a la paleta por defecto. Conservas bg-blue-600 y compañía, y agregas bg-primary. Es la opción segura por defecto, y la que usamos aquí.

La misma lógica aplica a todo lo demás que Tailwind lee del theme: no solo colores. Tu escala de espaciado, tu escala tipográfica, tus radios, tus sombras —todo eso se configura en theme.extend.spacing, theme.extend.fontSize, etc., mapeando tus tokens del módulo 2 y tus escalas del módulo 4 a los nombres de las utilidades. Así, p-4 puede leer tu paso de espaciado, text-lg tu paso tipográfico, rounded-md tu radio. En esta lección nos concentramos en los colores porque son el caso donde el theming (claro/oscuro) hace el enganche más visible, pero el mecanismo es el mismo para toda escala: la config es el puente entre tus tokens/escalas y los nombres de las utilidades.

Una nota de honestidad sobre versiones: la forma exacta de escribir la config evoluciona entre versiones de Tailwind (dónde vive el archivo, si se usa JavaScript o CSS para declararla). Lo que no cambia es el concepto de esta lección —mapear tus tokens a los nombres de las utilidades para que estas los consuman—. Cuando configures un proyecto real, confirma la sintaxis vigente en la documentación oficial (enlace en Recursos); el modelo mental que instalaste aquí se traslada intacto.

Errores comunes

Usar theme.colors cuando querías theme.extend.colors. Qué pasa: mapeas tus roles en theme.colors (sin extend) y de repente bg-gray-100, bg-blue-600 y toda la paleta por defecto dejan de existir. Por qué pasa: no se distingue "sumar" de "reemplazar". Cómo detectarlo: utilidades de color que antes funcionaban ahora no generan nada, y solo te quedan tus roles. Cómo corregirlo: usa theme.extend.colors para agregar tus roles conservando la paleta base. Reserva theme.colors (reemplazo total) para cuando quieras a propósito un sistema cerrado que solo permita tus roles —una decisión deliberada, no un accidente—.

Mapear el hex en vez de la referencia al token. Qué pasa: en la config escribes primary: '#2563eb' en vez de primary: 'var(--color-primary)'. Por qué pasa: parece equivalente —"el color primary es este azul"—. Cómo detectarlo: bg-primary da el mismo azul en tema claro y oscuro; el theming dejó de funcionar. Cómo corregirlo: si mapeas el hex, congelas el valor en la utilidad y pierdes la capa de token que hace el theming. Mapea la referencia (var(--color-primary)): así la utilidad apunta al token, y el token —no la utilidad— decide el valor según el tema. La utilidad debe apuntar al bote, no al pigmento; el pigmento lo elige el token del módulo 2.

Recurrir a utilidades arbitrarias (bg-[#3b82f6]) en vez de configurar el token. Qué pasa: necesitas un color y lo metes con corchetes (bg-[#3b82f6]) para no tocar la config. Por qué pasa: es más rápido en el momento. Cómo detectarlo: tu marcado tiene hex sueltos entre corchetes en lugar de nombres de rol. Cómo corregirlo: bg-[#3b82f6] es el drift del módulo 1 vestido de utilidad —un valor crudo, copiado, que no cambia con el tema ni con un rebranding, y que nadie más en el equipo sabe que existe—. Configura el rol en theme.extend una vez y usa bg-primary, que hereda el theming. Las utilidades arbitrarias tienen su lugar (un valor verdaderamente único y sin rol), pero un color de marca nunca es ese caso: ese va como token.

Ejercicios

Ejercicio 1 — Escribe el mapeo. Tu módulo 2 definió el rol color.muted con custom property --color-muted. Escribe la entrada de theme.extend.colors que crearía las utilidades bg-muted y text-muted apuntando a ese token.

Ver solución
theme: {
  extend: {
    colors: {
      muted: 'var(--color-muted)',
    },
  },
}

La clave muted es el nombre que aparecerá en las utilidades (bg-muted, text-muted), y el valor es la referencia al token (var(--color-muted)), no su hex. Con eso, bg-muted generará background-color: var(--color-muted), que resolverá a #f3f4f6 en claro y #1f2937 en oscuro —heredando el theming que el módulo 2 le dio al rol muted—.

Ejercicio 2 — Diagnostica el theming roto. Un compañero configuró primary: '#2563eb' en theme.extend.colors y se queja de que bg-primary "no cambia en tema oscuro, se queda azul brillante". ¿Qué está mal y cómo se arregla?

Ver solución

Mapeó el hex ('#2563eb') en vez de la referencia al token ('var(--color-primary)'). Al congelar el valor en la config, bg-primary genera background-color: #2563eb —un azul fijo que no sabe nada de temas—, así que en oscuro sigue siendo ese azul. El arreglo:

// mal:  primary: '#2563eb'
// bien: primary: 'var(--color-primary)'

Con la referencia, bg-primary apunta al token, y el token del módulo 2 le da #2563eb en claro y #60a5fa en oscuro. El theming vuelve a funcionar porque el valor lo decide el token, no la utilidad.

Ejercicio 3 — Predice la salida. Sin correr nada, di qué imprimiría el ejemplo trabajado si agregáramos muted: 'var(--color-muted)' a themeColors, el primitivo 'gray.100': '#f3f4f6' y 'gray.800': '#1f2937' a primitives, el semántico 'color.muted': { light: 'gray.100', dark: 'gray.800' } a semantics, y cambiáramos el bucle a ['bg-primary', 'bg-muted'].

Ver solución

bg-primary sale igual, y bg-muted genera background-color: var(--color-muted), que resuelve a #f3f4f6 (light) / #1f2937 (dark):

=== la utilidad no trae color: apunta a tu token ===

  .bg-primary -> background-color: var(--color-primary)  -> #2563eb (light) / #60a5fa (dark)
  .bg-muted   -> background-color: var(--color-muted)    -> #f3f4f6 (light) / #1f2937 (dark)

(La alineación exacta de los espacios depende del padEnd(40); lo esencial es la cadena utilidad → var() → valor por tema.) Un rol nuevo en la config (muted) produjo una utilidad nueva (bg-muted) que hereda su theming del token —cero CSS escrito a mano, el mismo patrón del módulo 2—.

Resumen y siguiente paso

En esta lección conectaste las dos capas del sistema: theme.extend.colors en la configuración de Tailwind mapea cada nombre de rol (primary) a la referencia de tu token (var(--color-primary)), de modo que la utilidad bg-primary consume tu token del módulo 2 y hereda su theming claro/oscuro sin que toques el marcado. Con la fábrica a la que le enseñas tus colores viste la idea clave: le entregas las etiquetas de tus botes (las var()), no los pigmentos (los hex), para que las utilidades apunten al token y cambien con el tema. Lo ejecutaste de punta a punta: bg-primarybackground-color: var(--color-primary)#2563eb (claro) / #60a5fa (oscuro), con el resolveToken del módulo 2 cerrando la cadena. Y viste extend (sumar) vs reemplazar, y por qué el hex congelado o las utilidades arbitrarias rompen el theming.

Antes de avanzar deberías poder: escribir el theme.extend.colors que mapea un rol a su token; explicar por qué se mapea var(--color-primary) y no el hex; y distinguir theme.extend (suma) de theme.colors (reemplaza).

La lección 7 cierra el módulo con una herramienta de excepción: @apply y cuándo extraer. Hasta aquí la regla fue "combina utilidades en el marcado y no inventes clases". Pero hay un caso —una combinación de utilidades que se repite idéntica muchas veces— donde conviene agruparlas en una clase reutilizable. Vas a ver qué hace @apply por debajo (inlinea las declaraciones de las utilidades en una regla), cuándo usarlo, y —sobre todo— cuándo no, porque abusar de @apply te devuelve al CSS semántico que todo el módulo te enseñó a dejar atrás.

Recursos

  • Tailwind CSS, "Theme" — tailwindcss.com/docs/theme. Cómo Tailwind lee tu configuración de tema para generar utilidades; la página exacta de theme.extend y la diferencia con reemplazar. En inglés.
  • Tailwind CSS, "Colors" — tailwindcss.com/docs/colors. Cómo se definen y consumen los colores del tema, incluida la forma de apuntarlos a custom properties. En inglés.
  • shadcn/ui, "Theming" — ui.shadcn.com/docs/theming. Un ejemplo real de tokens semánticos en custom properties mapeados a utilidades de Tailwind —casi el mismo enganche que hiciste aquí—. En inglés.
  • Tailwind CSS, "Adding custom styles" (utilidades arbitrarias) — tailwindcss.com/docs/adding-custom-styles. Qué son las utilidades arbitrarias (bg-[#...]), cuándo tienen sentido y por qué un color de marca no es ese caso. En inglés.