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,--optimizey la opciónoptimizede 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 notysource(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-noneyoutline-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.
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:
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 propertiesque repite esos valores iniciales para los navegadores sin@propertyfiable. - Variables del tema en lugar de valores: 197 usos de
var(--...). Unabg-indigo-600esbackground-color: var(--color-indigo-600). - Bloques
@supportspara la opacidad concolor-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óntheme()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:
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
--minifypero no--optimize, y minifica con cssnano en lugar de Lightning CSS. Si usas la LTS: usanpx tailwindcss -i entrada.css -o salida.css --minify, o añadecssnanoa 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 hacervite 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:
- 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.
- 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_headersolo si el bloque interior no declara ninguno. Por eso cadalocationlleva sus propias cabeceras. Lo probé poniendo la CSP en el bloqueserver: ellocationque añadía suCache-Controlperdió 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.
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 detailwind.config.js. Si usas la LTS: escribecontent: ['./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
safelistyblocklisten 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:
@referenceo, 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:

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');

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:
- Fijar la variable desde un fichero
.js, como en el ejemplo. - Usar valores cerrados con clases o atributos
data-*, si son pocos:data-tema="verde". - 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:

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:

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

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
@layernativo ni depende de@propertyni decolor-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:

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'],
},
};

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:

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:

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>

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>

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
placeholderusado como etiqueta: desaparece al escribir, y axe lo da por válido como nombre.- El
outline-nonesin alternativa: al tabular al campo, el navegador diooutline: noney 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-nonegenera lo que en v4 esoutline-hidden, youtline-hiddenno existe. Si usas la LTS: escribeoutline-none, que en v3 ya hace que el contorno reaparezca en los colores forzados, y añade tu propiofocus-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 |

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

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>

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>

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>

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: definetheme: { colors: { marca: { 600: '#4f46e5' } }, borderRadius: { tarjeta: '0.75rem' } }directamente bajotheme, sinextend, 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
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@importde temas. Si usas la LTS: crea untailwind.config.jspor aplicación, comparte el tema conpresets: [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#2c2f36y#1f2228son un solo color de texto:--color-tinta, otext-slate-900.text-[14px]estext-sm.w-[305px]es casiw-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 |