Módulo 20: Producción y rendimiento

Objetivos del módulo

  • Medir el peso real de tu CSS en crudo, con gzip y con Brotli, y saber qué cifra importa
  • Entender por qué el CSS de Tailwind crece con las clases distintas y no con las páginas
  • Explicar por qué una misma página pesa algo más en v4 que en v3, y cuándo importa
  • Conocer las fases que recorre el CSS: escaneo, generación, optimización y compresión
  • Usar --minify, --optimize y la opción optimize de Vite, y saber qué hace cada una
  • Servir el CSS con compresión y caché larga, y explicar por qué el CDN de desarrollo no va a producción
  • Quitar del escaneo lo que no es código con source(), @source not y source(none)
  • Forzar y prohibir clases con @source inline() y @source not inline()
  • Escribir una política CSP compatible con Tailwind y conocer sus tres tropiezos reales
  • Saber qué ve de verdad un navegador antiguo con v4, y arreglar parte del problema con cssTarget
  • Comprobar la accesibilidad con herramientas y a mano: contraste, foco, colores forzados y movimiento
  • Elegir bien entre outline-none y outline-hidden
  • Aplicar el diseño con restricciones: pocas decisiones, escala cerrada y auditoría de valores arbitrarios
  • Organizar un proyecto grande con varias aplicaciones, un tema común y una hoja por aplicación
  • Saber qué es Tailwind Plus y cuándo merece la pena

1. Preparación del laboratorio

Hasta ahora has trabajado en desarrollo, donde lo único que importa es ver el resultado. Este módulo mira el otro lado: qué le llega al usuario, cuánto tarda, si funciona en su navegador y si puede usarlo con teclado.

Todas las cifras de este módulo se han medido con Tailwind 4.3.3, Tailwind 3.4.19, Vite 8.3.5 y Node 24, salvo las que citan otra fuente. Como página de prueba se usa tienda.html: una cabecera, tres tarjetas de producto, una tabla de pedidos y un formulario, con 118 clases distintas de Tailwind. Es una página pequeña pero realista, la clase de cosa que construirás en el proyecto final.

Para medir necesitas un guion corto. Node trae gzip y Brotli de serie, así que no hay que instalar nada:

// medir-css.mjs. Uso: node medir-css.mjs dist/assets
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';

const carpeta = process.argv[2] ?? 'dist/assets';
const kib = (n) => (n / 1024).toFixed(1).padStart(7) + ' KiB';

for (const nombre of fs.readdirSync(carpeta).filter((f) => f.endsWith('.css'))) {
  const datos = fs.readFileSync(path.join(carpeta, nombre));
  const gzip = zlib.gzipSync(datos, { level: 9 }).length;
  const brotli = zlib.brotliCompressSync(datos, {
    params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 },
  }).length;
  console.log(`${nombre}\n  crudo ${kib(datos.length)}  gzip ${kib(gzip)}  brotli ${kib(brotli)}`);
}
npm run build
node medir-css.mjs dist/assets
tienda-BRmhkmWB.css
  crudo    20.2 KiB  gzip     4.5 KiB  brotli     3.9 KiB

Si tu proyecto solo tiene index.html, Vite ya lo toma como entrada. Para probar otro nombre, como aquí tienda.html, indícalo en build.rollupOptions.input.


2. Cuánto pesa el CSS de Tailwind

La crítica clásica a Tailwind es que “genera muchísimo CSS”. Tuvo su parte de verdad: el anuncio de la versión 3.0 (diciembre de 2021) reconoce que antes del nuevo motor había que vigilar el tamaño del CSS en desarrollo, hasta el punto de limitar la paleta de colores. Hoy solo se genera lo que aparece en tu código, así que hay que medir antes de opinar.

Caso Crudo gzip Brotli
v4 sin ninguna clase (Preflight y poco más), con --minify 4165 1402 1173
v3 sin ninguna clase, con --minify 4754 1421 1203
Tienda en v4, con --minify 20 616 4652 4025
Tienda en v3, con --minify 13 821 3458 2995
Tienda en v4 con theme(static) 34 946 8434 6832
Las 1700 clases usadas en los módulos 00 a 17 de este curso 173 835 26 466 21 072
bootstrap.min.css 5.3.8, completo 232 111 30 669 22 970
@tailwindcss/browser 4.3.3, el CDN de desarrollo (JavaScript) 282 289 73 859 60 122

Tamaños en bytes. gzip con nivel 9 y Brotli con calidad 11.

Barras con el peso en Brotli: la tienda en v3 pesa 2,9 KiB, en v4 3,9 KiB, con theme static 6,7 KiB, escaneando documentación 15,2 KiB, las 1700 clases del curso 20,6 KiB, Bootstrap 22,4 KiB y el CDN de desarrollo 58,7 KiB

Figura 1. La barra naranja es un error de configuración que verás en la sección 5: casi cuadruplica el peso sin añadir nada a la página.

Lee tres cosas en esa tabla:

  • Una página real pesa unos 4 KiB comprimida. Eso es menos que muchas imágenes de icono, y mucho menos que una fotografía de producto.
  • Casi todo un curso de Tailwind cabe en lo que pesa Bootstrap. Esas 1700 clases son más de las que usa un proyecto normal, y aun así quedan por debajo de los 22,4 KiB de Bootstrap. Hay que ser justos: Bootstrap incluye componentes completos, así que no es una comparación de “lo mismo por menos”. Ambos resuelven problemas distintos, como viste en la introducción.
  • La cifra que importa es la comprimida. El crudo de la tienda es cinco veces mayor que lo que viaja por la red.

Crece con las clases distintas, no con las páginas

Cada clase genera una sola regla, por muchas veces que la uses. Si copias una tarjeta en cien páginas, el HTML crece, pero el CSS no cambia. Compilé la tienda una vez y luego cien copias de ella: las dos hojas miden exactamente 20 616 bytes. Solo añaden peso las clases que aparecen por primera vez.

Para ver cómo crece, he ido sumando las clases de los módulos de este mismo curso, en orden, y midiendo el resultado a cada paso:

Curva que sube de 3 KiB con 108 clases a 21 KiB con 1700 clases en Brotli, con la línea de gzip algo por encima y un punto naranja en la tienda de ejemplo

Figura 2. Con clases muy variadas, cada clase distinta cuesta unos 11 bytes en Brotli. Un proyecto real repite casi siempre las mismas.

De ahí sale una regla práctica: lo que engorda el CSS es la variedad, no la cantidad. Una interfaz coherente, con pocos colores y una sola escala de espacios, genera menos CSS que una interfaz con cincuenta tonos distintos. La sección 10 vuelve sobre esto.

Por qué v4 pesa más que v3 en esta página 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) los valores del tema se escriben directamente en cada regla y hay menos código de compatibilidad, por lo que una página pequeña pesa algo menos. Si usas la LTS: no tienes que hacer nada; la tienda pesa 2995 bytes en Brotli frente a 4025 en v4, a cambio de no tener variables del tema en tiempo de ejecución.

Puede sorprender: v4 es más nuevo y, en esta página, 1030 bytes más pesado en Brotli. No es un defecto, es el precio de lo que hace por dentro. En la hoja de la tienda encontré esto:

  • 43 reglas @property, que ocupan 2915 bytes antes de comprimir. Registran las variables internas (--tw-shadow, --tw-translate-x…) para que tengan tipo y valor inicial.
  • Un bloque @layer properties que repite esos valores iniciales para los navegadores sin @property fiable.
  • Variables del tema en lugar de valores: 197 usos de var(--...). Una bg-indigo-600 es background-color: var(--color-indigo-600).
  • Bloques @supports para la opacidad con color-mix(), con su alternativa.

Como esas partes son muy repetitivas, comprimen bien: la diferencia de 6795 bytes en crudo se queda en 1030 en Brotli. A cambio ganas lo que viste en el módulo 14: el tema vive en variables CSS y se puede leer y cambiar en el navegador sin recompilar.

Las variables del tema solo aparecen si se usan 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) el tema no genera variables CSS. Si usas la LTS: no existe este coste, y tampoco el acceso a los valores desde var(); usa la función theme() en tu CSS o declara a mano las variables que necesites en :root.

De los cientos de variables del tema por defecto, Tailwind solo emite las que tu código usa. Por eso la hoja vacía no llega a 5 KiB en crudo. Si necesitas que estén todas, por ejemplo porque un script lee --color-red-500, puedes pedirlo:

/* Todas las variables del tema por defecto */
@import "tailwindcss" theme(static);

/* O solo las de un bloque tuyo */
@theme static {
  --color-marca-600: #4f46e5;
}

La forma con theme(static) en el @import "tailwindcss" da error en 4.0 y funciona desde 4.1; @theme static funciona en todas las versiones 4. El coste es real: la tienda pasa de 4025 a 6832 bytes en Brotli, un 70 % más, sin cambiar nada en pantalla. Úsalo solo si algo lo necesita, y mejor con @theme static limitado a tus propias variables.


3. Cómo se minimiza

El CSS recorre cuatro fases antes de llegar al navegador. Tú intervienes en las dos primeras con lo que escribes y escaneas, y en las dos últimas con la configuración:

Cinco cajas en cadena: tu proyecto, Oxide trocea el texto en candidatos, el compilador genera CSS, Lightning CSS optimiza y minifica, y el servidor comprime

Figura 3. El peso de la tienda en cada fase. La compresión del servidor es el paso que más ahorra.

Lo que hace Lightning CSS no es solo quitar espacios. Compara la clase sr-only antes y después:

/* Sin optimizar (24 880 bytes en total) */
.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border-width: 0;
  }
/* Con --minify (20 616 bytes en total) */
.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}

Además de quitar espacios, ha reordenado las propiedades. También aplana el anidamiento y calcula valores, y esta parte tiene más consecuencias de las que parece.

Optimizar no es solo adelgazar

Sin optimizar, Tailwind deja reglas anidadas tal cual, así como la opacidad con color-mix():

/* Sin optimizar: anidado, y sin alternativa real para quien no conoce color-mix */
.bg-white\/90 {
  background-color: color-mix(in srgb, #fff 90%, transparent);
  @supports (color: color-mix(in lab, red, red)) {
    background-color: color-mix(in oklab, var(--color-white) 90%, transparent);
  }
}
/* Con --minify: el color se ha calculado y el anidamiento ha desaparecido */
.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}

Fíjate en #ffffffe6: Lightning CSS ha convertido el color-mix() en un color normal que entiende cualquier navegador. Sin optimizar, esa alternativa no existe. Publicar sin optimizar no solo pesa más: funciona en menos navegadores.

Las opciones de la CLI 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) la CLI tiene --minify pero no --optimize, y minifica con cssnano en lugar de Lightning CSS. Si usas la LTS: usa npx tailwindcss -i entrada.css -o salida.css --minify, o añade cssnano a tu configuración de PostCSS.

npx @tailwindcss/cli -i app.css -o dist/app.css            # Sin optimizar
npx @tailwindcss/cli -i app.css -o dist/app.css --optimize # Optimiza, sin minificar
npx @tailwindcss/cli -i app.css -o dist/app.css --minify   # Optimiza y minifica
Orden Crudo gzip Brotli
Sin opciones 24 880 4955 4250
--optimize 25 597 4965 4261
--minify 20 616 4652 4025

--optimize da un fichero mayor que no poner nada. No es un error: esa opción transforma para compatibilidad (aplana lo anidado y añade prefijos como -webkit-) y no quita espacios. Sirve para leer el resultado final; en producción, usa --minify.

La compresión reduce mucho el beneficio de minificar: de 24 880 a 20 616 bytes hay un 17 % en crudo, pero en Brotli la mejora es de un 5 %. Aun así merece la pena, porque minificar es gratis y, como acabas de ver, también mejora la compatibilidad.

En Vite 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) no existe @tailwindcss/vite: Tailwind se usa como plugin de PostCSS. Si usas la LTS: Vite minifica igualmente el CSS al hacer vite build, y no hay que configurar nada.

Con vite build no tienes que hacer nada: el plugin optimiza y Vite minifica. Estas son las combinaciones, medidas sobre la tienda:

Configuración Crudo Brotli
Por defecto 20 640 4023
tailwindcss({ optimize: false }) y build.cssMinify: false 24 880 4250
tailwindcss({ optimize: { minify: false } }) y build.cssMinify: false 25 597 4261

Deja el valor por defecto. Las otras dos filas solo sirven para depurar lo que genera Tailwind.

💡 Consejo: Vite pone un resumen del contenido en el nombre: tienda-BRmhkmWB.css. Si cambia un solo byte, cambia el nombre. Ese detalle es la clave de la caché que viene ahora.

Comprimir y cachear en el servidor

Vite no comprime nada: deja .css normales en dist/, y es el servidor quien comprime al enviarlos. La mayoría de plataformas de alojamiento ya lo hacen por ti; en un servidor propio hay que activarlo. Esta configuración de nginx se ha probado con la imagen nginx:alpine en Docker:

server {
  listen 80;
  root /usr/share/nginx/html;

  gzip on;
  gzip_comp_level 6;
  gzip_types text/css;
  gzip_vary on;

  # Ficheros con hash en el nombre: se guardan un año sin revalidar
  location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    try_files $uri =404;
  }

  # El HTML apunta al hash actual: siempre se revalida
  location / {
    add_header Cache-Control "no-cache";
    add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'";
    try_files $uri $uri/ =404;
  }
}
$ curl -I -H "Accept-Encoding: gzip" http://127.0.0.1/assets/tienda-BRmhkmWB.css
HTTP/1.1 200 OK
Vary: Accept-Encoding
Cache-Control: public, max-age=31536000, immutable
Content-Encoding: gzip

Por la red viajaron 4655 bytes de los 20 640 originales.

La idea de las dos reglas de caché es esta:

  1. El CSS tiene un hash en el nombre, así que nunca cambia: si cambia el contenido, será otro fichero. El navegador puede guardarlo un año sin preguntar.
  2. El HTML no lleva hash, así que siempre se revalida. Al publicar, el HTML nuevo apunta a un CSS nuevo, y el usuario lo recibe de inmediato.

A partir de la segunda página vista, el CSS cuesta cero bytes.

⚠️ Importante: nginx hereda add_header solo si el bloque interior no declara ninguno. Por eso cada location lleva sus propias cabeceras. Lo probé poniendo la CSP en el bloque server: el location que añadía su Cache-Control perdió la CSP sin ningún aviso.

La imagen nginx:alpine no trae Brotli (nginx -V no lo menciona). Con gzip tienes casi todo el beneficio: 4655 frente a unos 4000 bytes.

Por qué el CDN de desarrollo no va a producción

En el módulo 01 viste @tailwindcss/browser, que compila en el propio navegador. Es cómodo para un prototipo, pero mira lo que supone. Comparé la misma tienda con CSS compilado y con ese script, ambos con gzip y sin caché, con la mediana de 9 cargas en Chromium:

Condiciones CSS compilado @tailwindcss/browser
Primer pintado, red local 264 ms 444 ms
Primer pintado, red lenta (150 ms de latencia, 1,6 Mbit/s) y CPU 4 veces más lenta 1612 ms 2720 ms
Bytes que se descargan para los estilos (gzip) 4652 73 859

El usuario espera 1,1 segundos más, y además su móvil hace el trabajo que debía haber hecho tu ordenador al compilar. También es incompatible con una política CSP estricta, como verás en la sección 7.


4. ¿Y el HTML?

Las utilidades mueven parte del peso del CSS al HTML, y hay que medir el conjunto. Monté 100 tarjetas de dos formas: con utilidades directas y con clases semánticas (.tarjeta, .tarjeta-titulo…) definidas con @apply.

100 tarjetas HTML crudo HTML gzip CSS gzip Total gzip Total Brotli
Utilidades 46 290 1245 2360 3605 2816
Clases semánticas con @apply 26 590 988 2290 3278 2601

En crudo, las utilidades casi duplican el HTML. Comprimido, la diferencia total son 215 bytes en Brotli para cien tarjetas. El motivo es que gzip y Brotli funcionan buscando repeticiones, y flex flex-col rounded-lg border... repetida cien veces es exactamente lo que comprimen mejor.

Ahora el caso contrario. @apply copia las declaraciones en cada clase que lo usa, de modo que el CSS sí crece si tienes variantes casi iguales. Con seis clases .boton-N que aplican las mismas utilidades, frente a usar seis veces las utilidades directamente:

Seis botones iguales CSS crudo CSS gzip
Seis clases con @apply 8572 1925
Utilidades directas 5623 1906

Ninguna de las dos diferencias decide nada: elige según lo que se mantiene mejor, como viste en el módulo 16, y no según el peso. El rendimiento no es un buen argumento ni a favor ni en contra de @apply.


5. Controlar qué se escanea

Oxide no entiende HTML: trata cada fichero como texto, lo trocea y comprueba si cada trozo es una utilidad. Lo viste en el módulo 02. Eso tiene una consecuencia que muchos proyectos pagan sin saberlo: cualquier fichero de texto puede añadir CSS a tu hoja.

Tres columnas: lo que se ignora por defecto, cómo funciona la detección automática y sus riesgos, y las directivas de ajuste fino

Figura 4. Por defecto se escanea casi todo, salvo unas pocas excepciones.

El error más caro del módulo

Monté este proyecto, con una carpeta de documentación y unos datos en JSON:

proyecto/
├── src/
│   ├── index.html          (la tienda)
│   └── estilos/app.css     (@import "tailwindcss";)
├── docs/                   (tres guías en Markdown)
└── datos/productos.json
npx @tailwindcss/cli -i src/estilos/app.css -o dist/app.css --minify
Entrada Crudo gzip Brotli
@import "tailwindcss"; 154 598 19 119 15 534
Con exclusiones de docs y datos 20 616 4652 4025

La hoja es 7,5 veces mayor por tres ficheros que el navegador nunca verá. Las guías mencionan clases en sus ejemplos, y Tailwind las genera todas: 900 clases en lugar de 118. Las 782 que sobran no las usa ninguna página.

El JSON también aporta su parte con mucho menos texto. Este fichero:

[{ "nombre": "Mesa", "tipo": "table", "estado": "hidden", "descr": "block fixed static flex grid container" }]

añade a la hoja .fixed, .static, .container y .table. Las palabras corrientes en inglés coinciden con clases de Tailwind.

📘 Concepto: La detección automática parte del directorio de trabajo, y no del fichero CSS. Si ejecutas la compilación en la raíz de un repositorio, se escanea todo lo que haya allí salvo lo ignorado. Por eso, en un repositorio con carpetas ajenas al proyecto, el resultado depende de dónde lances la orden.

Lo que se ignora por defecto es, según la documentación oficial, node_modules, lo que aparezca en .gitignore, los binarios, los ficheros CSS y los ficheros lock. He comprobado que .gitignore se respeta aunque la carpeta no sea un repositorio Git.

Elegir la base con source() 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) no hay detección automática: solo se escanea lo que indiques en content, dentro de tailwind.config.js. Si usas la LTS: escribe content: ['./src/**/*.{html,js,jsx}']; ese error de escanear de más solo te ocurre si pones un patrón demasiado amplio.

La solución más limpia es decirle a Tailwind dónde empieza tu código. La ruta es relativa al fichero CSS:

/* src/estilos/app.css */
@import "tailwindcss" source("..");

Si quieres controlarlo todo, apaga la detección y enumera las rutas:

@import "tailwindcss" source(none);
@source "../index.html";
@source "../componentes";

Las dos formas dieron los mismos 20 616 bytes en el proyecto anterior. source(none) es la más segura en proyectos grandes, porque una carpeta nueva no entra en el escaneo sin que nadie lo haya decidido. Su inconveniente es justo ese: si olvidas añadir una ruta, las clases de esa carpeta no se generarán.

Excluir rutas con @source not 🆕 v4.1

🆕 Desde Tailwind 4.1. No existe en la LTS (Tailwind 3.4). Si usas la LTS: añade un patrón negativo a content: content: ['./src/**/*.html', '!./src/legado/**'], comprobado en 3.4.19.

Cuando casi todo interesa salvo alguna carpeta, excluye:

@import "tailwindcss";
@source not "../../docs";
@source not "../../datos";

En Tailwind 4.0 esta línea da el error `@source` paths must be quoted. Lo mismo ocurre con @source inline().

Forzar y prohibir clases con @source inline() 🆕 v4.1

🆕 Desde Tailwind 4.1. No existe en la LTS (Tailwind 3.4). Si usas la LTS: usa safelist y blocklist en la configuración: safelist: ['underline', { pattern: /^bg-red-(100|200|300)$/, variants: ['hover'] }], blocklist: ['hidden'], comprobado en 3.4.19.

A veces una clase no aparece en ningún fichero porque viene de una base de datos o de un CMS. Para esos casos:

@import "tailwindcss";

/* Fuerza bg-red-100, 200 y 300, también con hover: */
@source inline("{hover:,}bg-red-{100..300..100}");

/* Impide que se genere .hidden aunque aparezca en el código */
@source not inline("hidden");

La sintaxis de llaves se expande igual que en la consola: {100..300..100} significa de 100 a 300 de 100 en 100, y {hover:,} significa “con hover: y sin nada”. Comprobado: .bg-red-200 y .hover:bg-red-300 se generan, y .hidden no, aunque esté en el HTML.

Usa esto con mucha moderación. Cada clase forzada es CSS que se envía siempre, y una expresión como bg-{red,blue,green}-{50,{100..900..100},950} añade decenas de reglas de golpe. Si puedes, escribe las clases completas en un objeto de JavaScript, como viste en el módulo 18 al hablar de bg-${color}-500.

Librerías y node_modules

node_modules se ignora, de modo que si una librería trae clases de Tailwind debes declararla. Apunta solo a la librería concreta:

@import "tailwindcss";
@source "../node_modules/@empresa/ui/dist";

Nunca escribas @source "../node_modules": escanearías miles de paquetes.


6. Tiempos de compilación

Tailwind Labs publicó esta comparación al lanzar la versión 4.0, usando su kit Catalyst, con la mediana de los tiempos (artículo oficial):

Medida de Tailwind Labs v3.4 v4.0 Mejora
Compilación completa 378 ms 100 ms 3,78 veces
Recompilación con CSS nuevo 44 ms 5 ms 8,8 veces
Recompilación sin CSS nuevo 35 ms 192 µs 182 veces

Mis medidas son de otro tipo: la orden completa, incluido el arranque de Node y la escritura del fichero, sobre 2000 páginas HTML (11,9 MB) con 8 clases variadas distintas en cada una. Es la mediana de 7 ejecuciones en un Intel Core i7-9750H con Windows, con otros procesos en marcha, de modo que las cifras absolutas son orientativas:

Compilación completa de 2000 páginas v3.4.19 v4.3.3 Relación
Sin minificar 53,9 s 7,4 s 7,3 veces
Con --minify 76,8 s 8,7 s 8,8 veces

En otra máquina tendrás otras cifras, y por eso importa la relación y no el número. Además, en el día a día compilas casi siempre de forma incremental, y ahí es donde cuentan las cifras oficiales.

Las exclusiones también ahorran tiempo, aunque menos que peso. Con 3000 ficheros antiguos añadidos a esas 2000 páginas, la mediana de 5 ejecuciones fue de 17,7 s al escanear todo y de 13,4 s con @source not. Los tiempos variaron mucho entre ejecuciones (de 13 a 25 s en el primer caso), porque el equipo estaba ocupado con otros trabajos. Tómalo solo como una tendencia.

⚠️ Importante: la documentación oficial desaconseja combinar CSS Modules y Tailwind por rendimiento. Las herramientas procesan cada módulo por separado, así que “si tienes 50 módulos CSS, Tailwind tiene que ejecutarse 50 veces”. Si debes usarlos, lo viste en el módulo 18: @reference o, mejor aún, variables CSS, que evitan ese procesado.


7. CSP: la política de seguridad de contenidos

La CSP (Content Security Policy) es una cabecera HTTP que le dice al navegador de dónde puede cargar cada cosa. Si un atacante consigue inyectar HTML en tu página, una buena CSP impide que su contenido se ejecute o aplique. Es una defensa importante, y Tailwind se lleva bien con ella, con tres excepciones que conviene conocer.

Todas estas pruebas se hicieron en Chromium con esta política:

default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self'

Significa que los estilos, los scripts y las imágenes solo pueden venir de tu dominio, y que no se permite nada en línea.

El caso normal funciona sin tocar nada

Tailwind compila a un .css externo. Eso incluye los valores arbitrarios: bg-[#b91c1c] y w-[37%] son reglas normales del fichero, y no estilos en línea. No hubo ninguna violación:

Tarjeta de producto con todos sus estilos y una etiqueta roja de valor arbitrario, bajo una CSP estricta

Figura 5. Con CSS compilado, la política más estricta no afecta en nada. Esta es una ventaja real frente a escribir style="...".

Eso es una ventaja poco comentada de Tailwind: lo que antes habrías escrito en style="color: red", que necesita permisos especiales, ahora es una clase en un fichero.

Tropiezo 1: las variables en el atributo style

El truco de pasar un valor dinámico con una variable CSS, que has usado en varios módulos, sí es un estilo en línea:

<!-- Bloqueado por style-src 'self' -->
<div class="bg-(--marca)" style="--marca: #0f766e">Marca</div>

<!-- Permitido: se fija desde un fichero .js -->
<div id="porjs" class="bg-(--marca)">Marca</div>
// fija.js, servido desde tu dominio
document.getElementById('porjs').style.setProperty('--marca', '#0f766e');

Solo se ve una barra verde, la fijada desde JavaScript; la del atributo style aparece vacía y su texto blanco no se lee

Figura 6. El navegador informó de style-src-attr inline. El primer elemento se queda sin fondo, y su texto blanco resulta invisible sobre el blanco.

La razón es que la CSP bloquea el atributo style en el HTML, pero no las modificaciones a través de la API element.style desde un script autorizado. Tienes tres salidas:

  1. Fijar la variable desde un fichero .js, como en el ejemplo.
  2. Usar valores cerrados con clases o atributos data-*, si son pocos: data-tema="verde".
  3. Relajar solo los atributos con style-src-attr 'unsafe-inline'. Con esa directiva el ejemplo funcionó, pero debilitas la protección.

Tropiezo 2: @tailwindcss/browser

Este script genera CSS en el navegador y lo inserta en una etiqueta <style>, que la política bloquea:

La tarjeta aparece sin ningún estilo: título negro grande con la fuente por defecto y texto plano

Figura 7. Con @tailwindcss/browser servido desde el mismo dominio se producen cuatro violaciones de style-src-elem, y no se aplica ningún estilo.

El paquete no pone nonce a su etiqueta <style> (he buscado en el código de la versión 4.3.3), así que el único modo de hacerlo funcionar es style-src 'self' 'unsafe-inline', que es justo lo que quieres evitar. Otro motivo más para no llevarlo a producción.

Tropiezo 3: los iconos de @tailwindcss/forms

El plugin de formularios dibuja la marca de la casilla y la flecha del <select> con imágenes SVG en una URL data:. Con img-src 'self', se bloquean:

Casilla marcada sin el símbolo de verificación y un select sin flecha

Figura 8. Sin data:: la casilla se ve rellena pero sin su marca, y el <select> pierde la flecha. Nada avisa al usuario.

Casilla con su marca de verificación y select con su flecha

Figura 9. Con img-src 'self' data: todo aparece. Este es el permiso mínimo para usar el plugin.

La CSP durante el desarrollo

En vite dev depende de cómo cargues el CSS. Con un <link> en el HTML, una CSP estricta no dio ninguna violación. Si lo importas desde JavaScript, con import './app.css' como hace casi todo proyecto de React o Vue, Vite lo inyecta en una etiqueta <style> y la política lo bloquea (style-src-elem inline). Se resuelve con un nonce, un valor que autoriza esa etiqueta:

// vite.config.js
import tailwindcss from '@tailwindcss/vite';

export default {
  plugins: [tailwindcss()],
  html: { cspNonce: 'abc123' },
  server: {
    headers: {
      'Content-Security-Policy':
        "default-src 'self'; script-src 'self' 'nonce-abc123'; style-src 'self' 'nonce-abc123'; connect-src 'self' ws:",
    },
  },
};

Con esta configuración no hubo violaciones. Es solo para desarrollo: en producción un nonce fijo no sirve de nada, porque debe generarse uno distinto en cada respuesta. Ten en cuenta que vite build extrae el CSS a un fichero y este problema desaparece.

Resumen de la política

Si usas Necesitas
CSS compilado, incluidos valores arbitrarios style-src 'self'
@tailwindcss/forms Añadir img-src data:
style="--variable: ..." en el HTML Mover a .js, o style-src-attr 'unsafe-inline'
@tailwindcss/browser style-src 'unsafe-inline' (no lo uses en producción)
Fuentes alojadas en otro dominio Añadirlo en font-src y style-src

8. Compatibilidad y alternativas 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) el CSS funciona en navegadores mucho más antiguos, porque no usa @layer nativo ni depende de @property ni de color-mix(). Si usas la LTS: no tienes que hacer nada; esta es la razón principal para quedarse en 3.4.

Según la documentación oficial, v4 depende de Chrome 111, Safari 16.4 y Firefox 128, publicados en marzo de 2023 los dos primeros y en julio de 2024 el tercero. Eso dice qué navegadores se soportan, pero no qué ocurre en los demás. Para saberlo descargué dos versiones reales de Chromium y cargué en ellas la tienda.

Función Chromium 98 Chromium 105 Chromium actual
@layer No Sí Sí
@property Sí Sí Sí
oklch() No No Sí
color-mix() No No Sí

Detectado en cada versión con CSS.supports() y comprobando las API.

Chromium 105: sin colores

Aquí está la tienda de v4, compilada con --minify:

La tienda en Chromium 105 con v4: bordes negros, sin fondos de color, sin degradados y el botón del carrito invisible

Figura 10. La estructura se mantiene, pero casi todos los colores se pierden. El botón “Carrito (2)” es blanco sobre blanco: el texto existe, pero nadie puede leerlo.

La causa es cómo funcionan las variables CSS. El tema declara --color-indigo-600: oklch(51.1% 0.262 276.966). El navegador no valida el contenido de una variable, así que la acepta. Pero al usarla en background-color: var(--color-indigo-600) descubre que no entiende el valor. Esa regla no se ignora para pasar a la anterior, como ocurre con un valor erróneo normal. La propiedad queda como si no se hubiera escrito. Por eso una declaración previa no sirve de alternativa.

Lo que sí se conserva es lo que Tailwind y Lightning CSS resuelven por adelantado. El bg-white/90 de la cabecera sigue siendo blanco gracias a #ffffffe6, que viste en la sección 3.

Recuperar los colores con cssTarget

Vite usa Lightning CSS para adaptar el CSS a los navegadores que indiques:

// vite.config.js
import tailwindcss from '@tailwindcss/vite';

export default {
  plugins: [tailwindcss()],
  build: {
    cssTarget: ['chrome105'],
  },
};

La tienda en Chromium 105 con todos sus colores, degradados y el botón del carrito visible

Figura 11. La misma tienda, ahora compilada con cssTarget: ['chrome105']. El coste son 387 bytes en crudo.

Lightning CSS sustituye cada color por dos valores: --color-indigo-600:#4f39f6 para todos, y lab(...) para quien lo soporte. Así que los navegadores modernos no pierden colores vivos.

Esto no convierte a Chromium 105 en un navegador soportado. Los colores vuelven, pero otras funciones de v4 pueden fallar, y Tailwind Labs no lo prueba. Tómalo como un parche razonable si solo un pequeño porcentaje de tus usuarios va atrasado.

Chromium 98: sin nada

Sin @layer, el problema es de otra magnitud:

La tienda en Chromium 98 con v4: texto negro con fuente Times, enlaces azules subrayados, una lista con viñetas y sin ningún diseño

Figura 12. Todo el CSS de Tailwind está dentro de bloques @layer, y el navegador los ignora enteros. La página se muestra como HTML sin estilos.

cssTarget tampoco lo arregla: @layer sigue en el fichero (5 apariciones) y la captura salió idéntica. Según los datos de compatibilidad de MDN, @layer llegó en Chrome 99, Firefox 97 y Safari 15.4. Si necesitas navegadores de esa época, la respuesta no es un parche, es la versión 3.4:

La tienda con Tailwind 3.4 en Chromium 105, con todos los colores y degradados

Figura 13. La misma página con Tailwind 3.4 en Chromium 105. También se ve bien en Chromium 98.

El resultado es instructivo: el fallo de v4 no es gradual. Un navegador justo por debajo del mínimo no se ve “un poco peor”: puede dejar botones invisibles o perder todo el diseño.

Antes de decidir: mira tus datos

¿Cuántos usuarios van con navegadores antiguos? La respuesta está en las analíticas de tu propia web, no en las estadísticas globales. Una tienda para el público general y una intranet con ordenadores gestionados son mundos distintos. Puedes usar esta guía:

Situación Decisión
Tus visitas superan los mínimos de v4 Usa v4 sin más
Unos pocos usuarios van atrasados, sin oklch() pero con @layer v4 con cssTarget y prueba en esas versiones
Parte importante del público sin @layer, o contrato que exige navegadores antiguos Tailwind 3.4
Dispositivos que no se actualizan: kioscos, pantallas de TPV, televisores Tailwind 3.4, y prueba en el aparato real

Puedes evitar que el texto sea ilegible en una función concreta con la variante supports-*, disponible en las dos versiones:

<button class="bg-black text-white supports-[color:color-mix(in_lab,red,red)]:bg-indigo-600">
  Carrito
</button>

Fuera de ese caso, no merece la pena: no es razonable repetir cada color de un proyecto.

Dos recordatorios del módulo 01: Sass, Less y Stylus no son compatibles con v4, y la propia documentación lo resume en “piensa en Tailwind CSS como tu preprocesador”. Además, como quien mantiene un proyecto en v3 suele querer migrar más adelante, repasa el módulo 19.


9. Accesibilidad

Tailwind no hace tu web accesible ni inaccesible: solo aplica los estilos que le pides. Pero al controlar tú cada detalle visual, también puedes eliminar lo que el navegador te daba gratis. Preflight, por ejemplo, quita el aspecto de botones, títulos y listas. Si conviertes un <div> en algo que parece un botón, nadie te avisa de que no lo es.

Medir con axe

axe es un motor de comprobación de código abierto. Se puede inyectar en una página de Playwright:

await pagina.addScriptTag({ path: 'node_modules/axe-core/axe.min.js' });
const resultado = await pagina.evaluate(() => axe.run({
  runOnly: ['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa', 'best-practice'],
}));

Esta es una tarjeta que parece correcta. Hay muchas así en producción:

<div class="max-w-md rounded-lg border border-slate-200 p-5 shadow-md">
  <div class="text-xl font-bold">Nuevo pedido</div>
  <p class="mt-1 text-sm text-slate-400">Revisa los datos antes de confirmar.</p>
  <img src="/teclado.svg" class="mt-3 h-16 w-full rounded-md bg-indigo-100">
  <input type="email" placeholder="Correo del cliente"
         class="mt-3 w-full rounded-md border border-slate-200 px-3 py-2 text-sm outline-none focus:shadow-lg">
  <div class="mt-3 flex items-center gap-2">
    <div onclick="confirmar()"
         class="cursor-pointer rounded-md bg-indigo-400 px-4 py-2 text-sm font-semibold text-white">Confirmar</div>
    <button class="rounded-md border border-slate-200 p-1 text-slate-400 outline-none">
      <svg viewBox="0 0 20 20" class="size-4" fill="currentColor"><!-- aspa --></svg>
    </button>
  </div>
  <p class="mt-3 text-xs text-red-300">Stock insuficiente</p>
</div>

Tarjeta de pedido limpia a primera vista, con texto gris claro, un botón lila pálido y un aviso rosa muy tenue

Figura 14. Parece una tarjeta normal. Tiene al menos diez problemas.

Resultado de axe 4.14 con la página completa:

AXE mala   violaciones 7
    button-name          critical  1 elemento
    image-alt            critical  1 elemento
    color-contrast       serious   3 elementos
    html-has-lang        serious   1 elemento
    landmark-one-main    moderate  1 elemento
    page-has-heading-one moderate  1 elemento
    region               moderate  6 elementos

Y la versión corregida:

<main>
  <section aria-labelledby="titulo" class="max-w-md rounded-lg border border-slate-300 p-5 shadow-md">
    <h1 id="titulo" class="text-xl font-bold">Nuevo pedido</h1>
    <p class="mt-1 text-sm text-slate-600">Revisa los datos antes de confirmar.</p>
    <img src="/teclado.svg" alt="Teclado mecánico de la tienda" class="mt-3 h-16 w-full rounded-md bg-indigo-100">
    <label for="correo" class="mt-3 block text-sm font-medium">Correo del cliente</label>
    <input id="correo" type="email" autocomplete="email" aria-describedby="error-stock"
           class="mt-1 w-full rounded-md border border-slate-500 px-3 py-2 text-sm
                  focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-700">
    <div class="mt-3 flex items-center gap-2">
      <button type="submit" class="min-h-11 rounded-md bg-indigo-700 px-4 py-2 text-sm font-semibold text-white
                                   hover:bg-indigo-800 focus-visible:outline-2 focus-visible:outline-offset-2
                                   focus-visible:outline-indigo-700">Confirmar</button>
      <button type="button" class="grid size-11 place-items-center rounded-md border border-slate-500 text-slate-700
                                   focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-700">
        <svg aria-hidden="true" viewBox="0 0 20 20" class="size-4" fill="currentColor"><!-- aspa --></svg>
        <span class="sr-only">Cancelar el pedido</span>
      </button>
    </div>
    <p id="error-stock" class="mt-3 text-sm font-medium text-red-700">Stock insuficiente</p>
  </section>
</main>

La misma tarjeta con texto más oscuro, una etiqueta visible sobre el campo, botones de 44 píxeles y un aviso rojo legible

Figura 15. Con lang en <html> y un <title> descriptivo, axe pasa de 7 violaciones a 0. Visualmente cambia poco.

⚠️ Importante: cero violaciones en axe no significa “accesible”. En la página mala, axe no detectó tres errores graves:

  • El <div onclick> que hace de botón: no se alcanza con el tabulador ni se activa con el teclado.
  • El placeholder usado como etiqueta: desaparece al escribir, y axe lo da por válido como nombre.
  • El outline-none sin alternativa: al tabular al campo, el navegador dio outline: none y solo un cambio de sombra casi invisible.

Las herramientas encuentran una parte de los errores. Además, siempre hay que recorrer la página con el teclado.

Contraste real de la paleta

Las pautas WCAG 2.2 piden, en el nivel AA, un contraste de 4,5:1 para el texto normal y de 3:1 para el texto grande (desde 18 pt, o 14 pt en negrita) y para los componentes de interfaz. Estas son las proporciones reales sobre blanco, calculadas con los colores que pinta el navegador con la paleta de v4:

Tono 300 400 500 600 700
slate 1,49 2,63 4,76 7,58 10,36
gray 1,47 2,60 4,84 7,56 10,30
zinc 1,48 2,62 4,83 7,72 10,43
indigo 2,01 3,13 4,58 6,46 8,09
red 1,92 2,89 3,81 4,77 6,42
emerald 1,52 1,94 2,47 3,65 5,36
amber 1,45 1,72 2,13 3,20 5,03

En negrita, el primer tono de cada fila que cumple 4,5:1 sobre blanco.

Observa dos cosas. El popular text-gray-400 se queda en 2,6:1, muy lejos del mínimo. Y no hay un número mágico: “del 500 hacia arriba” sirve para los grises y el índigo, pero el rojo necesita 600 y el verde y el ámbar necesitan 700. Por eso text-emerald-600 en una etiqueta “En stock” suspende, aunque parezca oscuro.

Sobre fondo oscuro todo se invierte. Sobre slate-900, slate-500 da solo 3,74:1 y slate-400 llega a 6,78:1. Por eso text-slate-500 no vale para los dos modos, y hay que escribir text-slate-600 dark:text-slate-400, como en el módulo 12.

El foco: outline-none y outline-hidden 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) la clase outline-none genera lo que en v4 es outline-hidden, y outline-hidden no existe. Si usas la LTS: escribe outline-none, que en v3 ya hace que el contorno reaparezca en los colores forzados, y añade tu propio focus-visible:.

Este es uno de los falsos amigos: el nombre es igual, pero su significado cambió. Compara lo que genera v4:

.outline-none {
  --tw-outline-style: none;
  outline-style: none;
}

.outline-hidden {
  --tw-outline-style: none;
  outline-style: none;
  @media (forced-colors: active) {
    outline: 2px solid transparent;
    outline-offset: 2px;
  }
}

La segunda parece más rara, pero es la correcta. El modo de colores forzados, que usan personas con baja visión (por ejemplo, los temas de contraste de Windows), reemplaza todos los colores, así que un contorno transparent se vuelve visible. Y lo importante: los fondos y las sombras desaparecen, de modo que si tu indicador de foco es una sombra, en ese modo no queda nada.

La prueba en Chromium con foco sobre cada botón, en colores normales y en forzados:

Clase Normal Colores forzados
outline-none Sin contorno Sin contorno
outline-hidden Sin contorno Contorno de 2 px
focus-visible:outline-2 Contorno de 2 px Contorno de 2 px

Tres botones de color índigo y dos etiquetas con fondo en el modo normal

Figura 16. Modo normal, con el foco en el botón central: outline-hidden no muestra nada.

Modo de colores forzados: los fondos desaparecen, el botón central tiene un contorno negro, la etiqueta sin borde pierde su forma y solo queda visible un círculo

Figura 17. Colores forzados: el botón con foco muestra el contorno, y la etiqueta sin borde pierde su forma. La otra conserva el suyo, y por eso conviene poner border aunque coincida con el fondo. El primer círculo de estado desaparece, porque era solo un fondo; el segundo sigue visible gracias a las clases que verás a continuación.

Regla sencilla: nunca quites el foco sin poner otro. Lo más robusto es usar focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-700, que además solo aparece al navegar por teclado, y no al hacer clic. Las WCAG lo exigen en su criterio 2.4.7, nivel AA.

Para un punto de color que debe conservar su significado en ese modo:

<div role="img" aria-label="Activo"
     class="size-9 rounded-full bg-emerald-500
            forced-colors:forced-color-adjust-none forced-colors:bg-[Highlight]"></div>

Las variantes forced-colors: y forced-color-adjust-none existen también en 3.4.

El enlace para saltar al contenido

Quien navega con teclado tiene que atravesar todo el menú en cada página. Un enlace oculto que aparece al recibir el foco lo evita. Está en la tienda:

<a href="#contenido"
   class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:rounded-md
          focus:bg-white focus:px-4 focus:py-2 focus:shadow-lg">Saltar al contenido</a>

Al pulsar el tabulador aparece en la parte superior un enlace Saltar al contenido sobre fondo índigo

Figura 18. sr-only lo saca de la vista sin quitarlo del árbol de accesibilidad; focus:not-sr-only lo devuelve al tabular.

No uses hidden ni invisible para eso: esas clases también lo ocultan a los lectores de pantalla y al teclado.

Movimiento reducido

Las animaciones pueden marear a algunas personas. La preferencia del sistema se respeta con dos variantes:

<!-- Se anima por defecto, y se quita para quien lo pide -->
<div class="animate-spin motion-reduce:animate-none"></div>

<!-- No se anima salvo que el usuario no haya pedido lo contrario -->
<div class="motion-safe:animate-spin"></div>

Con la preferencia reduce emulada, las dos dieron animation-name: none. La segunda forma es más segura, porque si la olvidas no anima. Sin esa preferencia, ambas giran con normalidad. Lo viste con detalle en el módulo 13.

Tamaño de los objetivos táctiles 🆕 v4.1

🆕 Desde Tailwind 4.1. No existe en la LTS (Tailwind 3.4). Si usas la LTS: usa una variante arbitraria: [@media(pointer:coarse)]:size-11, comprobada en 3.4.19.

Las WCAG piden al menos 24 por 24 píxeles CSS en el nivel AA (criterio 2.5.8) y 44 por 44 en AAA (criterio 2.5.5). Con la escala por defecto, size-11 son 44 px, y size-6 son 24. Puedes aumentarlo solo en pantallas táctiles:

<button class="size-8 pointer-coarse:size-11">...</button>

Lista rápida

Comprobación Cómo
Elementos nativos: <button>, <a>, <label>, <h1>… Revisa los <div> con onclick
Solo una <h1>, y lang en <html> axe
Contraste de 4,5:1 en el texto normal axe, y la tabla anterior
Imágenes con alt, y alt="" si son decorativas axe
Todo se alcanza y se activa con teclado A mano, con Tab y Enter
Foco siempre visible A mano, sin ratón
Se entiende en colores forzados En Chrome: Herramientas de desarrollo, Rendering, forced-colors
No depende solo del color Añade texto o icono a los estados
aria-invalid en los errores Estílalo con aria-invalid: (en 3.4, aria-[invalid=true]:)

10. Diseño con restricciones

Tailwind parte de una idea de diseño, no solo técnica. Sus creadores, Adam Wathan y Steve Schoger, la explicaron en su libro Refactoring UI, cuyo lema es “Design with tactics, not talent”: diseña con tácticas, no con talento. La táctica central: decide los valores una vez, y luego elige entre pocos.

Sin escala, cada decisión es nueva: ¿13 px o 14? ¿#6d7580 o #7a828e? Compara dos tarjetas escritas de forma independiente, como lo harían dos personas de un equipo:

<article class="m-[7px] w-[305px] rounded-[9px] border border-[#d9dde3] bg-white p-[13px] shadow-[0_3px_9px_rgba(0,0,0,.13)]">
  <h3 class="text-[17px] leading-[23px] font-[650] text-[#22262b]">Teclado mecánico</h3>
  <p class="mt-[5px] text-[13.5px] leading-[19px] text-[#6d7580]">Interruptores silenciosos.</p>
  <!-- ... -->
</article>

Dos tarjetas de producto con tamaños, márgenes y tonos ligeramente distintos, sin alinear

Figura 19. Con valores arbitrarios. Nada está exactamente mal, pero parecen de dos webs distintas.

<article class="m-2 w-76 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
  <h3 class="text-lg font-semibold text-slate-900">Teclado mecánico</h3>
  <p class="mt-1 text-sm text-slate-600">Interruptores silenciosos.</p>
  <!-- ... -->
</article>

Dos tarjetas de producto idénticas en proporciones, con el mismo color y bien alineadas

Figura 20. Con la escala del tema. Dos personas llegan al mismo resultado sin hablarlo.

Los valores arbitrarios son una vía de escape para casos concretos, no un modo de trabajar. Usarlos a menudo significa que estás escribiendo CSS con otra sintaxis. Además, generan más reglas: recuerda que el peso sigue a la variedad.

Cerrar la paleta 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) el tema se limita en tailwind.config.js. Si usas la LTS: define theme: { colors: { marca: { 600: '#4f46e5' } }, borderRadius: { tarjeta: '0.75rem' } } directamente bajo theme, sin extend, y el resto de valores desaparecerá.

Puedes hacer imposible lo que no quieres que se use. Con --color-*: initial, viste en el módulo 14, desaparecen los colores por defecto:

@import "tailwindcss" source(none);
@source "./a.html";

@theme {
  --color-*: initial;
  --radius-*: initial;
  --color-marca-600: #4f46e5;
  --color-neutro-900: #1e293b;
  --color-blanco: #fff;
  --radius-tarjeta: 0.75rem;
}

Con este tema, medí qué se genera a partir del HTML:

Clase ¿Se genera?
bg-marca-600, text-neutro-900, rounded-tarjeta Sí
bg-red-500, text-gray-900, rounded-lg No, en silencio
p-[13px] Sí: los valores arbitrarios siguen abiertos
shadow-md Sí: no has reiniciado --shadow-*

Una clase fuera del tema no da error, simplemente no pinta nada. En el editor, la extensión de VS Code del módulo 01 deja de sugerirla. Fíjate en que --spacing admite mucha libertad: mt-17 funciona siempre. La disciplina en los valores arbitrarios solo se puede comprobar con una auditoría.

Auditar los valores arbitrarios

Este guion cuenta cuántas clases llevan corchetes. No hay nada especial en él, y puedes adaptarlo a tu proyecto:

// auditar-clases.mjs. Uso: node auditar-clases.mjs src
import fs from 'node:fs';
import path from 'node:path';

const raiz = process.argv[2] ?? 'src';
const extensiones = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte']);
const recuento = new Map();
let totalClases = 0;

function recorrer(carpeta) {
  for (const entrada of fs.readdirSync(carpeta, { withFileTypes: true })) {
    const ruta = path.join(carpeta, entrada.name);
    if (entrada.isDirectory()) recorrer(ruta);
    else if (extensiones.has(path.extname(entrada.name))) analizar(ruta);
  }
}

function analizar(ruta) {
  const texto = fs.readFileSync(ruta, 'utf8');
  for (const [, valor] of texto.matchAll(/class(?:Name)?="([^"]+)"/g)) {
    for (const clase of valor.split(/\s+/).filter(Boolean)) {
      totalClases++;
      if (/\[[^\]]+\]/.test(clase)) recuento.set(clase, (recuento.get(clase) ?? 0) + 1);
    }
  }
}

recorrer(raiz);
const arbitrarias = [...recuento.values()].reduce((a, b) => a + b, 0);
console.log(`${totalClases} usos de clase, ${arbitrarias} con valor arbitrario (${(arbitrarias / totalClases * 100).toFixed(1)} %)`);
for (const [clase, veces] of [...recuento].sort((a, b) => b[1] - a[1]).slice(0, 10)) {
  console.log(String(veces).padStart(4), clase);
}

Sobre las dos páginas anteriores:

127 usos de clase, 45 con valor arbitrario (35.4 %)
   2 w-[305px]
   2 text-[#22262b]
   2 text-[14px]
   1 m-[7px]
   1 rounded-[9px]
   ...

No busques cero: un grid-cols-[1fr_2fr] o un aspect-[3/2] puntual son legítimos. Lo que delata un problema es un valor que se repite: si text-[#22262b] aparece muchas veces, es un color de marca que debería estar en el tema, y si aparece una vez con un tono parecido a otro, probablemente sobre.


11. Organizar un proyecto grande

Un proyecto pequeño cabe en un app.css y poco más. Cuando hay varios equipos y aplicaciones, las reglas de esta sección evitan los problemas más habituales.

Una estructura que escala

Un paquete de tema se importa desde las hojas de la tienda y del panel, y estas escanean el paquete de componentes compartidos

Figura 21. El tema es CSS que se importa, y los componentes son código que se escanea. Son dos relaciones distintas.

empresa/
├── packages/
│   ├── tema/tema.css            (solo @theme)
│   └── ui/src/Boton.jsx         (componentes con clases)
└── apps/
    ├── tienda/src/app.css
    └── panel/src/app.css
/* packages/tema/tema.css */
@theme {
  --color-marca-600: #4f46e5;
  --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
  --radius-tarjeta: 0.75rem;
}
/* apps/tienda/src/app.css */
@import "tailwindcss" source(none);
@import "../../../packages/tema/tema.css";

@source ".";
@source "../../../packages/ui/src";

Con esto, cada aplicación escanea su código y el de la librería común, y el tema está en un solo sitio.

Una hoja por aplicación 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) no hay source(none) ni @import de temas. Si usas la LTS: crea un tailwind.config.js por aplicación, comparte el tema con presets: [tema] y elige la configuración con @config "./panel.config.js"; en cada hoja. Probado en 3.4.19.

Si la tienda pública y el panel de administración comparten hoja, cada visitante descarga las clases de las dos. Lo medí con la tienda y un panel de 400 clases variadas:

Hojas Crudo Brotli
publico.css 20 616 4025
panel.css 50 482 8443
Una sola hoja para ambos 58 841 9375

El cliente de la tienda pasa de 9375 a 4025 bytes. Y además, el <h1> del panel no puede afectar por accidente a la tienda. Esa separación compensa si las dos partes tienen público o diseño distintos. Si una web comparte el 90 % de sus clases, no te compliques: una sola hoja.

El orden de las cosas en la hoja

/* app.css: siempre en este orden */
@import "tailwindcss" source(none);          /* 1. Tailwind */
@import "../tema.css";                       /* 2. Tema */
@plugin "@tailwindcss/forms";                /* 3. Plugins */

@source ".";                                 /* 4. Qué se escanea */

@custom-variant dark (&:where(.dark, .dark *)); /* 5. Variantes */

@utility texto-equilibrado {                 /* 6. Utilidades propias */
  text-wrap: balance;
}

@layer components {                          /* 7. Componentes, los mínimos */
  .prosa-tienda { @apply max-w-prose; }
}

Con muchos componentes, divide cada bloque en su fichero y deja app.css como índice de @import.

Convivir con otro CSS

Al introducir Tailwind en un proyecto con CSS antiguo, las clases pueden chocar. Para este caso existe el prefijo, visto en el módulo 19:

@import "tailwindcss" prefix(tw);
<div class="tw:flex tw:hover:bg-indigo-600"></div>

Ten en cuenta también que el CSS antiguo, si no está en ninguna capa, gana a todo lo que Tailwind pone dentro de @layer, como viste en el módulo 02. En una migración por etapas puede parecer que una utilidad no funciona, cuando lo que ocurre es que una regla sin capa la vence.

Normas de equipo

Lo que evita discusiones:

Norma Cómo se aplica
Orden de clases único prettier-plugin-tailwindcss, visto en el módulo 01
Las clases se escriben completas, nunca bg-${color}-500 Revisión de código, y objetos de mapeo
Combinar clases condicionales de forma segura tailwind-merge, clsx, o cva del módulo 16
Un color nuevo se añade al tema, no en un corchete Auditoría de la sección 10
Componente antes que @apply Si repites un bloque, extrae un componente del framework
El tamaño del CSS tiene un límite Presupuesto en integración continua

Un presupuesto de tamaño

Lo que no se mide empeora poco a poco. Basta con un guion que haga fallar la integración continua si la hoja supera un límite:

// comprobar-tamano.mjs. Uso: node comprobar-tamano.mjs dist/assets 30
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';

const [carpeta = 'dist/assets', limiteKib = '30'] = process.argv.slice(2);
const limite = Number(limiteKib) * 1024;
let excedidas = 0;

for (const nombre of fs.readdirSync(carpeta).filter((f) => f.endsWith('.css'))) {
  const datos = fs.readFileSync(path.join(carpeta, nombre));
  const peso = zlib.brotliCompressSync(datos).length;
  const correcto = peso <= limite;
  if (!correcto) excedidas++;
  console.log(`${correcto ? 'ok   ' : 'FALLA'} ${nombre}: ${(peso / 1024).toFixed(1)} KiB de ${limiteKib} KiB`);
}
process.exit(excedidas > 0 ? 1 : 0);
$ node comprobar-tamano.mjs dist/assets 30
ok    tienda-BRmhkmWB.css: 3.9 KiB de 30 KiB
$ echo $?
0

$ node comprobar-tamano.mjs dist/assets 2
FALLA tienda-BRmhkmWB.css: 3.9 KiB de 2 KiB
$ echo $?
1

Pon el límite algo por encima del tamaño actual. El guion habría detectado el fallo de la sección 5 en cuanto alguien añadiera una carpeta docs/.


12. Tailwind Plus

Tailwind CSS es gratuito y de código abierto. Tailwind Plus es el producto de pago de la misma empresa. Se llamaba Tailwind UI hasta que en marzo de 2025 cambió de nombre. Según ese anuncio, incluye sus “componentes, plantillas y el kit de interfaz Catalyst”, y es “un pago único con acceso de por vida”, sin suscripción.

Es importante entender lo que no es: no es una versión mejor de Tailwind ni una dependencia que se instale. Son fragmentos de código ya diseñados que copias en tu proyecto. No hay ninguna clase, función ni truco de rendimiento que solo tengan los clientes de pago, y todo lo que aparece en este curso funciona sin él.

Te conviene si No te hace falta si
Necesitas una interfaz cuidada y no tienes diseñador Tienes un sistema de diseño propio
Tu tiempo cuesta más que la licencia Estás aprendiendo: copiar te evita el ejercicio de construir
Quieres ver cómo resuelve un problema real quien hizo Tailwind Te bastan los componentes de código abierto de tu framework

Si lo necesitas, consulta en la web oficial el precio y las condiciones de la licencia, porque pueden cambiar. Y ten en cuenta que todo lo que copies pasa a ser tu código: debes comprobar su accesibilidad y su contraste como el resto.


13. Lista de comprobación antes de publicar

Área Comprobación
Peso medir-css.mjs da una cifra razonable en Brotli para tu tamaño de proyecto
Escaneo La compilación usa source() o @source not y no incluye documentación ni datos
Compilación Producción usa vite build o --minify, nunca el CSS sin optimizar
Entrega El servidor comprime y los ficheros con hash llevan caché larga
CDN No queda @tailwindcss/browser en ninguna página
CSP La política está activa y no hay violaciones en la consola
Navegadores Tus analíticas confirman que v4 cubre a tu público, o usas cssTarget o 3.4
Accesibilidad axe sin violaciones, y recorrido a mano con teclado
Modo oscuro El contraste se ha comprobado también sobre fondo oscuro
Colores forzados Los controles y el foco siguen visibles
Tema No hay colores repetidos en corchetes
Equipo Prettier y el presupuesto de tamaño corren en integración continua

14. Ejercicios

Ejercicio 1: Mide tu hoja

Compila la tienda con vite build y mide la hoja. ¿Qué parte del ahorro viene de minificar y cuál de comprimir?

Ver solución
npx vite build
node medir-css.mjs dist/assets
tienda-BRmhkmWB.css
  crudo    20.2 KiB  gzip     4.5 KiB  brotli     3.9 KiB

Sin optimizar son 24 880 bytes, y por tanto --minify ahorra un 17 %. Brotli baja de 20 640 a 4023 bytes, un 80 % más. La compresión hace casi todo el trabajo, y por eso nunca debe faltar en el servidor. Minificar sigue valiendo la pena, también porque resuelve el color-mix() y aplana el anidamiento, como viste en la sección 3.

Ejercicio 2: Presupuesto en integración continua

Escribe un guion que termine con código de error si alguna hoja CSS supera los 30 KiB en Brotli. Pruébalo con un límite que se cumpla y otro que no.

Ver solución

Es el comprobar-tamano.mjs de la sección 11. Lo que lo hace útil es process.exit(1): cualquier sistema de integración continua detiene el trabajo cuando una orden termina con un código distinto de 0.

$ node comprobar-tamano.mjs dist/assets 30   -> ok,    código 0
$ node comprobar-tamano.mjs dist/assets 2    -> FALLA, código 1

En un flujo real, va justo después de la compilación:

npm run build && node comprobar-tamano.mjs dist/assets 30

Ejercicio 3: Caza de clases fantasma

Un compañero ha añadido docs/ y datos/ al proyecto, y la hoja ha pasado de 4 KiB a 15 KiB en Brotli. Sin mirar el Markdown, averigua cuántas clases sobran y corrígelo. ¿Qué aporta el JSON por sí solo?

Ver solución

Compila sin minificar con y sin exclusiones, y compara los selectores. Este fragmento extrae las clases de cada resultado:

import fs from 'node:fs';

const clasesDe = (f) => new Set(
  [...fs.readFileSync(f, 'utf8').matchAll(/^\s*\.([a-zA-Z0-9_\\:\-\/\.\[\]%#]+)[ ,{]/gm)].map((m) => m[1]),
);
const todas = clasesDe('todo.css');
const limpias = clasesDe('limpio.css');
const sobran = [...todas].filter((c) => !limpias.has(c));
console.log(todas.size, limpias.size, sobran.length);
900 118 782

La corrección es:

@import "tailwindcss";
@source not "../../docs";
@source not "../../datos";

Quitando solo docs/, el JSON sigue aportando .fixed, .static, .container y .table, cuatro palabras de sus datos que también son clases. hidden, block, flex y grid también aparecían, pero la tienda ya las usaba.

En Tailwind 3.4 este error no se da si tu content solo apunta a src/, porque allí la lista de rutas es obligatoria.

Ejercicio 4: Clases que vienen del CMS

Las etiquetas de producto llegan del servidor con bg-red-100, bg-red-200 o bg-red-300, también con hover:. Esas clases no aparecen en el código. Haz que se generen, y evita a la vez que se genere .hidden. Da las dos versiones, v4 y v3.

Ver solución

En v4.1 o posterior:

@import "tailwindcss" source("..");
@source inline("{hover:,}bg-red-{100..300..100}");
@source not inline("hidden");

En 3.4:

// tailwind.config.js
module.exports = {
  content: ['./src/**/*.html'],
  safelist: [{ pattern: /^bg-red-(100|200|300)$/, variants: ['hover'] }],
  blocklist: ['hidden'],
};

En ambos casos comprobé que .bg-red-200 y .hover:bg-red-300 están en la hoja, y que .hidden no. Recuerda que la expresión regular de safelist se compara con los nombres de clase sin variantes, y que por eso hover va aparte.

Ejercicio 5: Color de marca bajo CSP

Tu aplicación deja elegir el color de cada usuario. Lo aplicas con style="--marca: ...", y al activar style-src 'self' el fondo desaparece. Corrígelo sin debilitar la política.

Ver solución

Mueve la asignación a un fichero .js del mismo dominio, porque la API element.style no es un estilo en línea para la CSP:

<div id="tarjeta" class="bg-(--marca) p-3 text-white" data-marca="#0f766e">Marca</div>
<script src="/marca.js"></script>
// marca.js
for (const el of document.querySelectorAll('[data-marca]')) {
  el.style.setProperty('--marca', el.dataset.marca);
}

En la prueba con ese método el fondo computado fue rgb(15, 118, 110) y no hubo violaciones, mientras que con el atributo el navegador informó de style-src-attr inline y el fondo fue transparente.

En 3.4, la clase es bg-[var(--marca)], y el resto no cambia.

Ejercicio 6: Cara a cara con axe

Parte de la tarjeta mala de la sección 9 y deja axe a cero. Después haz lo que axe no comprueba: recorre la tarjeta solo con el teclado e indica qué has tenido que cambiar además.

Ver solución

La solución completa es el HTML de la sección 9. Estos son los cambios, con la regla de axe que resuelve cada uno:

Cambio Regla
<html lang="es"> html-has-lang
<div> a <h1> page-has-heading-one
Envolver en <main> landmark-one-main, region
alt en la imagen image-alt
<span class="sr-only"> en el botón del aspa button-name
slate-400 a slate-600, indigo-400 a indigo-700, red-300 a red-700 color-contrast

Y lo que solo se detecta a mano:

Cambio Motivo
<div onclick> a <button> Se alcanza con Tab y se activa con Enter y espacio
placeholder a <label> La etiqueta no desaparece al escribir
outline-none a focus-visible:outline-2... El foco ha de verse
Borde del campo a slate-500 Los controles necesitan 3:1 (el borde slate-200 no llega)
size-11 en los botones Objetivo táctil de 44 px

Resultado: de 7 violaciones a 0, y 29 reglas superadas.

Ejercicio 7: Rescatar Chromium 105

Una empresa cliente tiene un parque de equipos con Chromium 105. Comprueba si la tienda de v4 es legible, y arregla lo que puedas sin migrar a v3. ¿Hasta dónde llega esa solución?

Ver solución
// vite.config.js
import tailwindcss from '@tailwindcss/vite';

export default {
  plugins: [tailwindcss()],
  build: { cssTarget: ['chrome105'] },
};

Antes de este cambio, los colores desaparecían porque oklch() no es válido en esa versión (figura 10). Después, --color-indigo-600 es #4f39f6, con lab(...) para los navegadores capaces, y la hoja pasa de 20 640 a 21 027 bytes.

Límites de esta solución: no sustituye a @layer, que llegó en Chrome 99. Con Chromium 98 la página sigue sin estilos. Tampoco garantiza nada que no hayas probado: revisa cada pantalla en ese navegador. Para descargar una versión antigua, puedes usar las compilaciones de Chromium publicadas en chromium-browser-snapshots y abrirlas con chrome --headless --screenshot.

Ejercicio 8: Una paleta cerrada

Haz que en tu proyecto solo existan los colores marca-600, neutro-900 y blanco, y el radio tarjeta. Comprueba con el CSS generado que bg-red-500 y rounded-lg dejan de funcionar.

Ver solución

Es el bloque @theme de la sección 10. Reiniciar con --color-*: initial y --radius-*: initial elimina el espacio de nombres completo, y luego declaras lo que quieres.

Para comprobarlo, busca las clases en la salida:

bg-marca-600    true
bg-red-500      false
text-neutro-900 true
text-gray-900   false
rounded-tarjeta true
rounded-lg      false
p-[13px]        true
shadow-md       true

Las dos últimas muestran los límites: los valores arbitrarios siguen disponibles y shadow-md también, porque no reiniciaste --shadow-*. Si quieres cerrar más, añade esos espacios de nombres, y deja la auditoría para los corchetes.

Ejercicio 9: Auditoría de un proyecto

Pasa auditar-clases.mjs por las dos versiones de tarjeta de la sección 10 y decide qué valores subirías al tema.

Ver solución
127 usos de clase, 45 con valor arbitrario (35.4 %)
   2 w-[305px]
   2 text-[#22262b]
   2 text-[14px]

Todos los valores repetidos apuntan a decisiones que deberían ser de tema o de escala:

  • text-[#22262b] y sus parecidos #2c2f36 y #1f2228 son un solo color de texto: --color-tinta, o text-slate-900.
  • text-[14px] es text-sm.
  • w-[305px] es casi w-76, que son 304 px.

Lo importante no es el 35,4 % en sí, sino el descenso tras el cambio: la versión con escala de la figura 20 tiene 0 valores arbitrarios.

Ejercicio 10 (propuesto): Dos hojas para dos públicos

Separa un proyecto con tienda y panel en publico.css y panel.css, cada una con source(none), un tema común importado y una carpeta compartida de componentes. Mide con medir-css.mjs qué se ahorra el cliente de la tienda respecto a una sola hoja, y decide si en tu caso compensa la complejidad añadida.

Ejercicio 11 (propuesto): Política para la tienda completa

Escribe una política CSP para el proyecto final que empiece por default-src 'none', y ve añadiendo solo lo que la consola de violaciones te pida. Comprueba con Playwright que acabas con cero violaciones, con el evento securitypolicyviolation.

Ejercicio 12 (propuesto): Pasada por colores forzados

Abre tu panel de control con forcedColors: 'active' en Playwright, o con la emulación de Chrome. Localiza lo que depende solo del fondo o de la sombra (etiquetas, barras de progreso, botones) y arréglalo con bordes o con la variante forced-colors:.


Resumen

Concepto API Idea clave
Peso real gzip y Brotli Mide el fichero comprimido; una página real ronda los 4 KiB
Crecimiento Clases distintas El CSS sigue a la variedad, no a las páginas
v3 frente a v4 @property, variables v4 pesa algo más a cambio de un tema vivo en el navegador
Tema completo @theme static, theme(static) Emite todas las variables; aquí supuso un 70 % más
Minificar --minify, vite build También aplana el anidamiento y calcula alternativas
--optimize CLI Compatibilidad sin minificar; puede dar más bytes
Caché immutable y no-cache Larga para ficheros con hash, revalidar el HTML
CDN de desarrollo @tailwindcss/browser Unas 15 veces más bytes y bloqueado por CSP: no va a producción
Escaneo source(".."), source(none) La base por defecto es el directorio de trabajo
Exclusión 🆕 v4.1 @source not En v3, content con !
Forzar clases 🆕 v4.1 @source inline(), @source not inline() En v3, safelist y blocklist
Falso positivo Docs y JSON “table” en un dato genera .table; aquí, 7,5 veces más CSS
CSP style-src 'self' Lo compilado funciona; style="" y data: son los tropiezos
Bajo el mínimo cssTarget Recupera colores sin oklch(), pero no @layer
Fallo sin @layer Navegadores anteriores a Chrome 99 Sin estilos: necesitas Tailwind 3.4
Pruebas automáticas axe Cero violaciones no garantiza accesibilidad
Contraste WCAG AA, 4,5:1 gray-400 da 2,6:1; verde y ámbar necesitan 700
Foco 🔄 v4.0 outline-hidden, focus-visible: Nunca quites el foco sin poner otro
Colores forzados forced-colors:, forced-color-adjust-none Bordes sí; fondos y sombras, no
Táctil 🆕 v4.1 pointer-coarse: 44 px en AAA, 24 px en AA
Restricciones --color-*: initial Decide una vez y elige entre pocos
Varias aplicaciones @import del tema, @source Una hoja por aplicación si los públicos difieren
Presupuesto Guion en CI Lo que no se mide empeora poco a poco
Tailwind Plus Producto de pago Código para copiar, no funciones adicionales