Módulo 19: Migración de v3 a v4
Objetivos del módulo
- Decidir con criterio si conviene migrar un proyecto o quedarse en Tailwind 3.4
- Preparar el proyecto para que cualquier fallo tenga vuelta atrás
- Ejecutar
npx @tailwindcss/upgradesobre un proyecto v3 real y leer lo que hace en cada fase - Reconocer lo que la herramienta hace bien, lo que tapa, lo que estropea y lo que ni siquiera ve
- Reparar a mano una migración hasta que la página vuelva a verse como antes
- Saber qué pasa con las opciones que no se traducen:
prefix,important,corePlugins,safelist,blocklist,separator, y con Sass - Consultar la lista completa de cambios incompatibles, con las diferencias medidas que la guía oficial no cuenta
- Diagnosticar los problemas frecuentes y automatizar la auditoría con un pequeño guion
🔄 Cambia en Tailwind 4.0. Todo este módulo trata del paso de la LTS (Tailwind 3.4) a v4, y en la LTS no hay nada que migrar. Si usas la LTS: lee la sección 1 para confirmar que quedarte es la decisión correcta y usa la sección 12 como lista de lo que te encontrarás el día que migres; empezar hoy a escribir
bg-black/50ygap-*, y evitar dos clases rivales en un mismo elemento, abarata ese día.
Este es el módulo que recoge todo lo que has visto marcado con 🔄 a lo largo del curso. No lo hemos escrito leyendo la guía oficial: hemos cogido una tienda en Tailwind 3.4.19, hemos ejecutado npx @tailwindcss/upgrade de la versión 4.3.3 y hemos medido el resultado en el navegador. Por eso aparece un fallo de la herramienta que no sale en ninguna guía, y clases que siguen en el proyecto después de migrar sin que nadie te avise.
1. ¿Hay que migrar? Cuándo no hacerlo
Migrar tiene un coste y v3 no deja de funcionar el día que sale v4. Antes de tocar nada, responde a estas preguntas. Tailwind no publica una política LTS formal: lo que existe es la etiqueta npm v3-lts, que apunta a 3.4.19, y la recomendación de la guía oficial de quedarse en 3.4 si necesitas navegadores antiguos.
| Pregunta | Si la respuesta es sí | Por qué |
|---|---|---|
| ¿Debes dar soporte a Safari anterior a 16.4, Chrome anterior a 111 o Firefox anterior a 128? | No migres | v4 se apoya en @property y color-mix(), y no hay modo de compatibilidad |
| ¿Tus hojas son Sass, Less o Stylus y están muy mezcladas con Tailwind? | No migres todavía | No están soportados. Un app.scss mínimo llega a compilar con aviso con @tailwindcss/postcss, pero nadie lo mantiene |
| ¿Dependes de un plugin o kit de componentes que solo funciona con v3? | Espera | Busca antes su versión para v4 |
| ¿El proyecto está en mantenimiento y casi no recibe cambios? | Probablemente no merece la pena | Pagas el riesgo y no aprovechas las novedades |
| ¿Tu diseño depende de los valores exactos de la paleta v3? | Migra con plan | Los colores cambian de valor sin cambiar de nombre (sección 12.1) |
¿Quieres @theme, variables del tema, consultas de contenedor nativas, mask-* o compilaciones más rápidas? | Migra | Es lo que justifica el esfuerzo. El módulo 00 lo resume |
⚠️ Importante: La pregunta de los navegadores se responde con datos, no con intuición. Mira las estadísticas de tu sitio. En un panel interno de empresa con navegadores gestionados la respuesta es inmediata; en una web pública para un público amplio, puede que no.
Qué no cambia
También conviene saber lo que no hay que tocar. Los nombres de la gran mayoría de las clases siguen igual: flex, p-4, text-sm, grid-cols-3, md:, hover:. El grueso del trabajo está en unas cuantas decenas de clases y en la configuración. El problema es que varias de esas clases fallan sin hacer ruido, y a eso dedicamos este módulo.
2. Preparar el proyecto
La herramienta modifica ficheros en su sitio, y como verás en la sección 5 puede detenerse a medias. Prepárate para poder volver atrás en un minuto:
- Comprueba Node. La herramienta exige Node 20 o superior. Solo la herramienta: tu proyecto puede no usarlo después.
- Termina lo que tengas a medias y haz commit. Con cambios sin confirmar, la herramienta se niega a trabajar.
- Crea una rama propia,
git switch -c migracion-tailwind-4. - Haz una línea base visual. Compila la versión actual y guarda capturas de todas las páginas importantes, en claro y en oscuro, y a varios anchos. Sin esto no podrás demostrar qué ha cambiado.
- Guarda una compilación de v3 en una carpeta fuera del control de versiones, para abrirla junto a la nueva.
node -v # 20 o superior
git switch -c migracion-tailwind-4
npm run build
cp -r dist ../tienda-v3-referencia
💡 Consejo: La línea base es lo que más tiempo ahorra. Muchos fallos de esta migración no dan ningún error, y la única forma de verlos es comparar dos pantallas. Nuestra captura de
dist-v3nos permitió encontrar en segundos el botón en blanco de la cabecera que verás después.
3. El proyecto de partida
Para que la prueba fuera honrada, hemos escrito una tienda con lo que suele tener un proyecto v3 real: Vite, PostCSS, tres plugins oficiales, un plugin propio, modo oscuro por clase, componentes con @apply, y clases dentro de JavaScript. También hemos dejado costumbres malas que aparecen en cualquier código con años: clases rivales en un mismo elemento y nombres de variable que coinciden con clases.
| Pieza | Contenido |
|---|---|
| Dependencias | tailwindcss 3.4.19, postcss, autoprefixer, vite 8.3.5, @tailwindcss/forms, @tailwindcss/typography, @tailwindcss/container-queries |
postcss.config.js | tailwindcss: {} y autoprefixer: {} |
tailwind.config.js | content, darkMode: 'class', container, colores, fuente, un punto de ruptura y una sombra |
plugins/tienda.js | Una variante y un componente |
src/estilos.css | @tailwind, @layer, @apply, theme() y @screen |
index.html | Cabecera, oferta, tarjetas, pedidos, formulario, modal |
src/app.js | Modal, clases en cadenas y nombres engañosos |
El fichero de configuración:
// tailwind.config.js
import forms from '@tailwindcss/forms';
import typography from '@tailwindcss/typography';
import contenedor from '@tailwindcss/container-queries';
import tienda from './plugins/tienda.js';
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.js'],
darkMode: 'class',
theme: {
container: {
center: true,
padding: '1rem',
},
extend: {
colors: {
marca: { 100: '#e0f2f1', 500: '#14b8a6', 700: '#0f766e', 900: '#134e4a' },
acento: '#ff7043',
},
fontFamily: { sans: ['Inter', 'Segoe UI', 'sans-serif'] },
screens: { xs: '480px' },
boxShadow: { tarjeta: '0 8px 24px rgb(0 0 0 / 0.12)' },
},
},
plugins: [forms, typography, contenedor, tienda],
};
La hoja de estilos:
/* src/estilos.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--aviso: #b45309;
}
@layer base {
h1 {
@apply tracking-tight;
}
a {
color: theme('colors.marca.700');
}
}
@layer components {
.boton {
@apply inline-block rounded bg-marca-700 px-4 py-2 font-semibold text-white shadow-sm;
}
.boton:hover {
background-color: theme('colors.marca.900');
}
.boton-peligro {
@apply boton bg-red-600;
}
.sello {
font-size: theme('fontSize.2xl');
color: theme('colors.acento');
}
}
@layer utilities {
.fondo-rayas {
background-image: repeating-linear-gradient(45deg, transparent 0 12px, rgb(0 0 0 / 0.03) 12px 24px);
}
}
@screen md {
.tabla-sombra {
box-shadow: theme('boxShadow.tarjeta');
}
}
Y el fragmento de JavaScript que tiene más miga:
// src/app.js (fragmento)
export function ocultarEtiqueta() {
etiqueta.classList.add('hidden');
}
// Clases escritas como cadenas
const estados = {
ok: 'bg-green-600 bg-opacity-90 shadow-sm rounded',
error: 'bg-red-600 bg-opacity-90 shadow rounded-sm',
};
// Nombres que coinciden con clases de Tailwind y no son clases
const shadow = 'sombra';
const blur = 3;
const outline = { ring: 'dorado' };
enviar.addEventListener('blur', () => console.log(shadow, blur, outline.ring));
enviar.dispatchEvent(new Event('blur'));
// Un texto que el usuario lee, no una clase
export const mensaje = 'El anillo se llama ring, la sombra shadow y el borde rounded';
Compilada con v3, esta es nuestra línea base:

Figura 1. La línea base: la tienda compilada con Tailwind 3.4.19.

Figura 2. La línea base en modo oscuro. Fíjate en que el botón de la cesta es translúcido.
4. Ejecutar la herramienta
La orden no necesita instalación ni argumentos. Se lanza desde la raíz del proyecto.
npx @tailwindcss/upgrade
Sus opciones caben en tres líneas:
| Opción | Efecto |
|---|---|
-c, --config | Ruta del fichero de configuración, si no está donde lo busca |
-f, --force | Trabaja aunque Git tenga cambios sin confirmar |
-h, --help / -v, --version | Ayuda y versión |
Si dejas cualquier cambio sin confirmar, se detiene antes de tocar nada:
≈ tailwindcss v4.3.3
│ Git directory is not clean. Please stash or commit your changes before migrating.
│ You may use the `--force` flag to silence this warning and perform the migration.
⚠️ Importante: No uses
--forcepara quitarte el aviso de encima. El aviso existe para quegit diffmuestre solo lo que ha hecho la herramienta, y esa revisión es la mitad del trabajo.
La herramienta avanza por fases fijas, y conviene conocerlas porque el orden explica lo que ocurre cuando algo falla.
Figura 3. Las fases de la herramienta. Las dos últimas llegan después de instalar la v4.
Observa algo importante: en la fase 4 ya ha instalado Tailwind 4 en tu proyecto, y todavía no ha tocado tus clases. Si se detiene en la fase 5, te queda un proyecto a medias.
5. Un fallo real: Cannot find module 'tailwindcss/plugin'
Nuestra primera ejecución no terminó. Este es su registro, recortado:
│ ↳ Upgrading from Tailwind CSS `v3.4.19`
│ Searching for CSS files in the current directory and its subdirectories…
│ ↳ Linked `.\tailwind.config.js` to `.\src\estilos.css`
│ Migrating JavaScript configuration files…
│ ↳ Migrated configuration file: `.\tailwind.config.js`
│ Migrating stylesheets…
│ ↳ Migrated stylesheet: `.\src\estilos.css`
│ Updating dependencies…
│ ↳ Updated package: `tailwindcss`
│ Migrating templates…
Error: Cannot find module 'tailwindcss/plugin'
Require stack:
- ...\tienda\plugins\tienda.js
La culpa la tiene la primera línea de nuestro plugin propio, una línea normalísima:
// plugins/tienda.js
import plugin from 'tailwindcss/plugin';
export default plugin(({ addVariant, addComponents }) => { /* ... */ });
Esa importación funciona con v3 y funciona con v4: lo hemos comprobado cargando el mismo plugin desde un proceso aparte, con Tailwind 4 ya instalado. Falla solo dentro de la herramienta, que carga el plugin antes de instalar v4 y vuelve a cargarlo después, en el mismo proceso. Nuestra hipótesis es que Node conserva lo que leyó del paquete v3, cuya estructura es distinta, aunque no lo hemos podido demostrar. El ensayo se hizo con Node 24.19 en Windows, y puede no darse en otras combinaciones.
El estado en que queda el proyecto
| Fichero | Estado tras el fallo |
|---|---|
src/estilos.css | Ya migrado |
package.json | Ya con tailwindcss 4 |
node_modules/tailwindcss | 4.3.3 |
index.html, src/app.js | Sin tocar |
postcss.config.js | Sin tocar, aún apunta al plugin de v3 |
tailwind.config.js | Sigue ahí |
La trampa: volver a ejecutarla
Lo natural es repetir la orden con --force. Lo probamos, y esta es la salida:
│ ↳ Upgrading from Tailwind CSS `v4.3.3`
│ Searching for CSS files in the current directory and its subdirectories…
│ Migrating stylesheets…
│ ↳ Migrated stylesheet: `.\src\estilos.css`
│ Updating dependencies…
│ ↳ Updated package: `tailwindcss`
│ Migrating templates…
│ ↳ Migrated `.\index.html`
│ ↳ Migrated templates for: `.\src\estilos.css` (1 file changed)
│ Verify the changes and commit them to your repository.
Termina con código de salida 0 y parece correcto, pero mira la primera línea: ahora cree que parte de v4.3.3, y en ese modo solo moderniza la escritura. Cambió bg-gradient-to-r, flex-shrink-0, !font-bold y text-[--aviso], pero no renombró shadow, rounded, ring ni outline-none, y dejó sin migrar src/app.js y postcss.config.js. Es el peor resultado posible: un proyecto con aspecto de migrado y las escalas desplazadas.
⚠️ Importante: Si la herramienta se detiene, no la repitas sobre el resultado. Vuelve al punto de partida con
git checkout .ynpm ci, comprueba quetailwindcssvuelve a ser 3.4.19, corrige la causa y empieza otra vez.
La solución
El envoltorio plugin() no es imprescindible: un plugin también puede ser la función sin envolver, y esto vale en las dos versiones.
// plugins/tienda.js
export default function tienda({ addVariant, addComponents }) {
addVariant('enviado', '&[data-estado="enviado"]');
addComponents({
'.insignia': {
display: 'inline-block',
padding: '0.125rem 0.5rem',
borderRadius: '9999px',
},
});
}
Confirmamos ese cambio, repetimos desde el principio y la migración terminó. Si prefieres el envoltorio, puedes restaurarlo después: Tailwind 4 lo carga sin problema.
6. Lo que hace bien
La ejecución correcta termina así:
│ Migrating templates…
│ ↳ Migrated `.\src\app.js`
│ ↳ Migrated `.\index.html`
│ ↳ Migrated templates for configuration file: `.\tailwind.config.js` (2 files changed)
│ Migrating PostCSS configuration…
│ ↳ Installed package: `@tailwindcss/postcss`
│ ↳ Removed package: `autoprefixer`
│ ↳ Migrated PostCSS configuration: `.\postcss.config.js`
│ Verify the changes and commit them to your repository.
git status confirma que ha cambiado siete ficheros, y que ha borrado tailwind.config.js:
M index.html
M package-lock.json
M package.json
M postcss.config.js
M src/app.js
M src/estilos.css
D tailwind.config.js
La configuración pasa a CSS
Es la parte en la que más brilla. Toda la configuración de JavaScript ha acabado en la hoja de estilos:
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@plugin '@tailwindcss/container-queries';
@plugin '../plugins/tienda.js';
@custom-variant dark (&:is(.dark *));
@utility container {
margin-inline: auto;
padding-inline: 1rem;
}
@theme {
--color-marca-100: #e0f2f1;
--color-marca-500: #14b8a6;
--color-marca-700: #0f766e;
--color-marca-900: #134e4a;
--color-acento: #ff7043;
--font-sans: Inter, Segoe UI, sans-serif;
--breakpoint-xs: 480px;
--shadow-tarjeta: 0 8px 24px rgb(0 0 0 / 0.12);
}
| v3 | v4, escrito por la herramienta | Detalle en |
|---|---|---|
plugins | @plugin, con la ruta relativa recalculada | módulo 15 |
darkMode: 'class' | @custom-variant dark | módulo 12 |
theme.container | @utility container | módulo 07 |
theme.extend.* | @theme | módulo 14 |
La hoja de estilos
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@utility boton {
@apply inline-block rounded-sm bg-marca-700 px-4 py-2 font-semibold text-white shadow-xs;
&:hover {
background-color: var(--color-marca-900);
}
}
@utility boton-peligro {
@apply boton bg-red-600;
}
@utility sello {
font-size: var(--text-2xl);
color: var(--color-acento);
}
@utility fondo-rayas {
background-image: repeating-linear-gradient(
45deg,
transparent 0 12px,
rgb(0 0 0 / 0.03) 12px 24px
);
}
@layer utilities {
:root {
--aviso: #b45309;
}
}
@layer base {
h1 {
@apply tracking-tight;
}
a {
color: var(--color-marca-700);
}
}
@media (width >= theme(--breakpoint-md)) {
.tabla-sombra {
box-shadow: var(--shadow-tarjeta);
}
}
Hay varios aciertos que se agradecen:
.botony.boton-peligropasan de@layer componentsa@utility. Sin esto,@apply botondaría un error en v4.- Dentro del
@applyha renombradoroundedarounded-smyshadow-smashadow-xs, en el orden correcto. theme('colors.marca.900')pasa avar(--color-marca-900), y el valor sigue siendo el mismo.@screen md, que v4 dejaría pasar sin procesar, se convierte en una consulta@mediareal.- La regla de compatibilidad de bordes mantiene el gris de v3 en toda la página.
Lo de :root dentro de @layer utilities es raro aunque funciona, y puedes moverlo a @layer base cuando repases el fichero.
Las dependencias
+ "@tailwindcss/postcss": "^4.3.3",
- "autoprefixer": "10.4.21",
- "tailwindcss": "3.4.19",
+ "tailwindcss": "^4.3.3",
// postcss.config.js
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};
autoprefixer desaparece porque v4 ya incluye los prefijos, como vimos en el módulo 01.
Las clases
Este es el cambio real en nuestros ficheros, sacado de git diff --word-diff:
| Antes | Después | Qué resuelve |
|---|---|---|
shadow-sm | shadow-xs | Escala desplazada |
shadow | shadow-sm | Escala desplazada |
rounded-sm | rounded-xs | Escala desplazada |
rounded | rounded-sm | Escala desplazada |
blur-sm | blur-xs | Escala desplazada |
focus:ring | focus:ring-3 | ring mide 1 px en v4 |
outline-none | outline-hidden | Cambio de significado |
outline outline-2 | outline-solid outline-2 | outline cambia de significado |
first:*:pt-0 | *:first:pt-0 | Orden de apilado |
!font-bold, hover:!underline | font-bold!, hover:underline! | Importante al final |
text-[--aviso] | text-(--aviso) | Sintaxis de variables |
bg-gradient-to-r | bg-linear-to-r | Nombre nuevo |
flex-shrink-0, flex-grow | shrink-0, grow | Alias obsoletos |
overflow-ellipsis, decoration-slice | text-ellipsis, box-decoration-slice | Alias obsoletos |
aria-[invalid=true]: | aria-invalid: | Forma corta de v4 |
has-[:checked]: | has-checked: | Forma corta de v4 |
La herramienta distingue además la mayoría de los nombres que no son clases: const shadow, const blur, outline.ring y addEventListener('blur', ...) quedaron intactos. Es un trabajo que una búsqueda y sustitución no podría hacer. Y hace los renombrados de una pasada, sin caer en la trampa de la figura 4.
Figura 4. Una búsqueda ingenua que cambie primero shadow por shadow-sm y luego shadow-sm por shadow-xs deja todas las sombras en shadow-xs.
7. Lo que deja sin resolver
Ahora viene lo que justifica que este módulo exista. El registro ha terminado con “Verify the changes”, sin una sola advertencia. Compilamos sin errores y abrimos la página.

Figura 5. La tienda justo después de npx @tailwindcss/upgrade. Sin errores y con cinco fallos visibles.
Comparada con la figura 1, tiene estos fallos:
| # | Lo que se ve | Clase | Causa |
|---|---|---|---|
| 1 | Botón de la cesta blanco y sin texto legible | bg-white bg-opacity-20 | bg-opacity-* no existe en v4: la clase no genera nada y el fondo queda opaco |
| 2 | Aparece “Agotado” en una tarjeta | hidden inline-flex | hidden pierde frente a inline-flex |
| 3 | El precio del portátil baja 32 px | mt-2 mt-10 | El ganador cambia de 8 a 40 px |
| 4 | El precio del monitor se desplaza a la derecha | ml-6 ms-2 | El ganador cambia de 8 a 24 px |
| 5 | El rojo de “Vaciar cesta” y de -20 % es más vivo | bg-red-600, bg-red-500 | La paleta cambia de valor sin cambiar de nombre |
Hay más fallos que una captura no enseña: el anillo de foco de focus:ring-3 usa ahora currentColor en lugar del azul de v3, los botones han perdido el cursor de mano, y la tarjeta del portátil crece de golpe al pasar el ratón porque transition-[opacity,transform] ya no cubre scale-*.
Y al abrir el modal:

Figura 6. El modal en v3: se ve la tienda detrás.

Figura 7. El mismo modal tras la herramienta: bg-black bg-opacity-50 ha pasado a ser negro opaco.

Figura 8. En oscuro, el botón blanco destaca todavía más.
Por qué no ha convertido bg-opacity-*
Es lo más sorprendente, porque la guía oficial dice que esas clases se eliminan y uno espera que la herramienta las traduzca. Pero la traducción no es por clase, sino por pareja: bg-white bg-opacity-20 debe dar bg-white/20, y para eso hay que combinar dos clases en una. En nuestro proyecto, las cinco apariciones de index.html y las tres de app.js siguieron igual, entre ellas hover:bg-opacity-30 y focus:ring-opacity-50.
Como en v4 estas clases no generan CSS, el compilador no se queja. El elemento simplemente pierde la transparencia. Búscalas siempre:
git grep -nE "(bg|text|border|divide|ring|placeholder)-opacity-"
Lo que hizo mal
Hay dos cambios que no son omisiones, sino errores:
- enviar.dispatchEvent(new Event('blur'));
+ enviar.dispatchEvent(new Event('blur-sm'));
- export const mensaje = 'El anillo se llama ring, la sombra shadow y el borde rounded';
+ export const mensaje = 'El anillo se llama ring, la sombra shadow-sm y el borde rounded-sm';
El primero es grave: el evento blur-sm no existe, el manejador deja de ejecutarse y JavaScript no da ningún error. El segundo cambia un texto que ve el usuario. Cualquier cadena de una sola palabra que coincida con una clase renombrada corre peligro, y una frase también, como acabas de ver. Por eso hay que leer el diff de los .js, los .ts y los componentes, línea por línea.
⚠️ Importante:
git diffde ficheros que no sean CSS es obligatorio. Fíjate en queaddEventListener('blur', ...)quedó bien y, en la línea de abajo,new Event('blur')quedó mal. Es una heurística: acierta muchas veces, pero no siempre.
El balance
Figura 9. El reparto real en nuestra tienda. La última columna no produjo ningún aviso.
8. Reparar a mano
Partiendo de la figura 5, estas son todas las correcciones que hicimos, en orden:
| Fichero | Antes | Después | Por qué |
|---|---|---|---|
index.html | bg-white bg-opacity-20 | bg-white/20 | Opacidad en el color |
index.html | hover:bg-opacity-30 | hover:bg-white/30 | Hay que repetir el color |
index.html | bg-red-500 bg-opacity-90 | bg-red-500/90 | Igual |
index.html | bg-black bg-opacity-50 | bg-black/50 | Igual |
index.html | focus:ring-marca-500 focus:ring-opacity-50 | focus:ring-marca-500/50 | Igual |
index.html | focus:ring-3 | focus:ring-3 focus:ring-blue-500/50 | El anillo azul de v3 ya no es el predeterminado |
index.html | transition-[opacity,transform] | transition-[opacity,scale] | scale-* ya no usa transform |
index.html | mt-2 mt-10 | mt-2 | Deja solo el valor que se veía |
index.html | ml-6 ms-2 | ms-2 | Igual |
index.html | hidden inline-flex | hidden | Sin clase rival |
index.html | max-[900px]:, min-[900px]: | max-[56.25rem]:, min-[56.25rem]: | Orden correcto y frontera limpia |
app.js | new Event('blur-sm') | new Event('blur') | Deshace el error |
app.js | shadow-sm y rounded-sm en la frase | shadow y rounded | Deshace el error |
app.js | bg-green-600 bg-opacity-90 | bg-green-600/90 | Opacidad, también en cadenas |
app.js | 'text-' + c + '-600 bg-opacity-50' | 'text-' + c + '-600/50' | Y mejor no construir clases así: módulo 18 |
app.js | etiqueta.classList.add('hidden') | etiqueta.hidden = true | El atributo gana a inline-flex |
estilos.css | --breakpoint-xs: 480px | --breakpoint-xs: 30rem | Unidad común |
estilos.css | Sin regla | button:not(:disabled) { cursor: pointer; } en @layer base | Mantiene el cursor de v3 |
Tras compilar, medimos los mismos elementos en las tres versiones:
| Medida | v3 | Tras la herramienta | Corregida |
|---|---|---|---|
| Fondo de la cesta | rgba(255,255,255,0.2) | rgb(255,255,255) | Blanco al 20 % |
| Cursor de la cesta | pointer | default | pointer |
| Margen del precio del portátil | 8 px | 40 px | 8 px |
| Margen izquierdo del precio del monitor | 8 px | 24 px | 8 px |
display de “Agotado” | none | flex | none |
| Sombra de la cabecera | 0 1px 3px y 0 1px 2px -1px | La misma | La misma |

Figura 10. La tienda corregida. Solo el rojo y otros colores de la paleta son algo más vivos que en la figura 1.

Figura 11. El modal recupera la transparencia.

Figura 12. El modo oscuro, con @custom-variant dark y las mismas clases dark:.
Hay una diferencia que dejamos a propósito. La tarjeta del medio tiene -20 % en un rojo más vivo, y el botón “Vaciar cesta” también, porque en v4 red-600 vale otra cosa. Si tu marca depende del valor exacto, fíjalo en el tema. Lo comprobamos con red-500, que vuelve a dar rgb(239, 68, 68):
@theme {
--color-red-500: #ef4444; /* el valor de v3 */
}
Limpiezas que la herramienta no hace
Una vez la página se ve bien, quedan tres tareas de calidad.
1. Pasar a @tailwindcss/vite. La herramienta ha mantenido PostCSS, aunque nuestro proyecto usa Vite, donde el plugin dedicado es lo recomendado desde el módulo 01.
npm i -D @tailwindcss/vite@4.3.3
npm rm @tailwindcss/postcss
rm postcss.config.js
// vite.config.js
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [tailwindcss()],
});
Lo probamos: la hoja compilada tiene exactamente el mismo tamaño, 49 051 bytes, que con PostCSS.
2. Quitar plugins sobrantes. @tailwindcss/container-queries sigue instalado, aunque v4 trae @container y @md: de serie, como vimos en el módulo 10. Puedes borrar su @plugin y desinstalarlo.
3. Retirar la compatibilidad de bordes, poco a poco. La regla border-color: var(--color-gray-200, currentcolor) aplica a todo el documento, y mientras exista el border de v4 no se comporta como explica este curso. Lo ideal es añadir border-gray-200 donde haga falta y luego borrar la regla.
El coste en tamaño
Nuestra hoja compilada pasó de 39 856 a 49 051 bytes, y de 7,1 a 8,4 KB comprimida con gzip. Son las variables del tema y las declaraciones @property. Es la medida de un proyecto pequeño, no una regla general. El módulo 20 trata el tamaño a fondo.
9. Configuraciones que no se traducen
La tienda tiene una configuración amable. Para ver los límites, migramos otro proyecto con todo lo incómodo:
// tailwind.config.js de un proyecto con historia
const colors = require('tailwindcss/colors');
module.exports = {
content: ['./index.html'],
prefix: 'tw-',
important: true,
darkMode: 'media',
safelist: ['tw-bg-red-500', { pattern: /tw-text-(green|amber)-600/ }],
blocklist: ['tw-container'],
corePlugins: { float: false },
future: { hoverOnlyWhenSupported: true },
theme: {
extend: {
colors: { primario: colors.indigo },
spacing: ({ theme }) => ({ tarjeta: '22rem' }),
keyframes: { latido: { '50%': { opacity: '.5' } } },
animation: { latido: 'latido 2s infinite' },
},
},
};
La herramienta no consiguió traducirlo a @theme. Dejó tailwind.config.js en su sitio y lo enlazó con @config:
@import 'tailwindcss' prefix(tw);
@config '../tailwind.config.js';
Las clases pasaron a la forma de prefijo como variante:
<!-- Antes -->
<div class="tw-flex hover:tw-bg-red-500 md:tw-p-4 tw-shadow tw-rounded !tw-font-bold -tw-mt-2 tw-bg-primario-500 tw-bg-opacity-50 nota">x</div>
<!-- Después -->
<div class="tw:flex tw:hover:bg-red-500 tw:md:p-4 tw:shadow tw:rounded tw:font-bold! tw:-mt-2 tw:bg-primario-500 tw-bg-opacity-50 nota">x</div>
Hay dos detalles en esa línea. tw-bg-opacity-50 queda con el prefijo viejo, y no significa nada. Y tw:shadow y tw:rounded no se renombraron: como en v4 esas formas sin sufijo son alias obsoletos con el valor de v3, no cambió el aspecto, pero sí conviene revisar los sufijos -sm en un proyecto con prefijo.
Al compilar con @config, esto es lo que ocurre con cada opción:
| Opción de v3 | Resultado medido en 4.3.3 | Qué hacer |
|---|---|---|
prefix: 'tw-' | Traducida a prefix(tw) | Nada, salvo revisar las clases dinámicas |
important: true | Respetada, las utilidades llevan !important | También puedes usar @import 'tailwindcss' important; |
Función en theme | Respetada, tw:w-tarjeta da 22rem | Pásala a --spacing-tarjeta cuando puedas |
keyframes y animation | Respetadas | Pásalas a @theme, módulo 13 |
corePlugins: { float: false } | Ignorada sin aviso: tw:float-left genera CSS | Elimina la clase de tus plantillas |
safelist | Ignorada sin aviso: text-green-600 no aparece | @source inline("...") |
separator: '_' | Ignorada sin aviso: md_flex no se convierte ni genera CSS | Reescribe las clases con : a mano |
blocklist | Sin efecto sobre tw:container, que genera CSS | Quita esas clases del código |
future.hoverOnlyWhenSupported | Innecesaria | Ya es el comportamiento de v4 |
El sustituto de safelist admite expansión con llaves. Esta línea, medida, genera bg-red-500, text-green-500, text-green-600, hover:text-green-500 y hover:text-green-600:
@source inline("bg-red-500 {hover:,}text-green-{500,600}");
📘 Concepto:
@configes un puente, no un destino. Los tokens que entran por ahí no generan variables CSS en:root, como vimos en el módulo 14, de modo que pierdes las principales ventajas de@theme. Úsalo para migrar por partes y ve vaciando el fichero.
Sass
Probamos un proyecto cuya única hoja era src/app.scss. La herramienta busca solo ficheros .css, y dice esto:
│ Before your project can be upgraded you need to create a CSS file that imports Tailwind CSS or uses `@tailwind`.
│ Updating dependencies…
│ ↳ Updated package: `tailwindcss`
│ Migrating PostCSS configuration…
│ ↳ Installed package: `@tailwindcss/postcss`
│ ↳ Migrated PostCSS configuration: `.\postcss.config.js`
│ Verify the changes and commit them to your repository.
Es decir: avisa, pero sigue adelante y cambia las dependencias y PostCSS, dejando un .scss escrito para v3 y sin tus clases migradas. Si renombras el fichero a .css no mejora nada: la herramienta metió la variable en una capa, @layer base { $marca: #0f766e; }, y dejó color: $marca tal cual.
La salida razonable es quitar Sass antes de migrar. El CSS actual ya tiene lo que más se usaba de Sass: anidamiento y variables, y los valores del tema se leen como var(--color-marca-700). Si el código Sass es tan grande que esto no compensa, esa es tu respuesta a la sección 1.
10. Migrar a mano
A veces no puedes usar la herramienta, o quieres migrar por partes. El orden que causa menos problemas es este:
- Instala
tailwindcssy el integrador:@tailwindcss/vite,@tailwindcss/postcsso@tailwindcss/cli. - Sustituye las tres directivas
@tailwindpor@import "tailwindcss";. Quitaautoprefixerypostcss-import. - Conecta tu configuración sin traducirla:
@config "../tailwind.config.js";. Con eso ya compila. - Añade la compatibilidad de bordes, anillos y botones de la sección 12.2 para no ver cambios todavía.
- Convierte
@layer componentsy@layer utilitiesa@utilitydonde uses@applyde esas clases o variantes comohover:boton. - Renombra las clases de las escalas, de la pequeña a la grande (sección 11).
- Elimina
*-opacity-*, y arregla el orden de las clases rivales. - Mueve el tema a
@themepor bloques, comprobando cada uno, y quita@configal final.
La ventaja del paso 3 es que puedes parar en cualquier punto con la página funcionando.
11. Tabla de renombrados
Esta es la referencia para buscar. La columna “Tienda” indica lo que vimos hacer a la herramienta en nuestra prueba; un guion significa que el proyecto no tenía esa clase.
Escalas desplazadas
| v3 | v4 | Valor | Tienda |
|---|---|---|---|
shadow-sm | shadow-xs | 0 1px 2px | Sí |
shadow | shadow-sm | 0 1px 3px | Sí |
drop-shadow-sm | drop-shadow-xs | 0 1px 1px | - |
drop-shadow | drop-shadow-sm | Ver sección 12.1 | - |
blur-sm | blur-xs | 4 px | Sí |
blur | blur-sm | 8 px | - |
backdrop-blur-sm | backdrop-blur-xs | 4 px | - |
backdrop-blur | backdrop-blur-sm | 8 px | - |
rounded-sm | rounded-xs | 2 px | Sí |
rounded | rounded-sm | 4 px | Sí |
Las formas sin sufijo (shadow, rounded, blur) siguen generándose en 4.3.3 como alias obsoletos con el mismo valor que en v3. Las formas -sm, en cambio, son falsos amigos: existen, y valen otra cosa.
Cambian de significado
| v3 | v4 | Por qué | Tienda |
|---|---|---|---|
ring | ring-3 | ring mide ahora 1 px | Sí |
outline-none | outline-hidden | outline-none de v4 quita de verdad el contorno | Sí |
outline | outline-solid | outline mide ahora 1 px | Sí |
Eliminadas de verdad
Estas seis no generan CSS en 4.3.3, medido una a una. Ninguna fue convertida en nuestra prueba.
| v3 | v4 |
|---|---|
bg-opacity-* | bg-black/50 |
text-opacity-* | text-black/50 |
border-opacity-* | border-black/50 |
divide-opacity-* | divide-black/50 |
ring-opacity-* | ring-black/50 |
placeholder-opacity-* | placeholder-black/50 |
Obsoletas que siguen funcionando
La guía oficial da por eliminadas flex-shrink-*, flex-grow-*, overflow-ellipsis, decoration-slice y decoration-clone, pero 4.3.3 todavía las genera. Si migras a mano y no las cambias, nada se rompe de momento. Cámbialas igualmente, porque son alias sin garantía de futuro.
| v3 | v4 | Tienda |
|---|---|---|
flex-shrink-* | shrink-* | Sí |
flex-grow-* | grow-* | Sí |
overflow-ellipsis | text-ellipsis | Sí |
decoration-slice | box-decoration-slice | Sí |
decoration-clone | box-decoration-clone | - |
bg-gradient-to-* | bg-linear-to-* | Sí |
bg-left-top | bg-top-left | - |
object-left-top | object-top-left | - |
break-words | wrap-break-word | - |
start-*, end-* | inset-s-*, inset-e-* (desde 4.2) | - |
12. Todos los cambios incompatibles
Esta sección reúne lo que el curso ha ido marcando con 🔄. Lo que merece más atención son los cambios marcados como silencioso: no dan error, y la compilación termina bien. Cada uno se ha medido compilando el mismo HTML con 3.4.19 y con 4.3.3 y leyendo getComputedStyle en Chromium.
12.1. Falsos amigos
Una clase puede existir en las dos versiones con distinto resultado. La guía oficial cuenta border, ring y las escalas, pero no drop-shadow-md/lg/xl.

Figura 13. Las mismas clases, compiladas con cada versión.
| Clase | Tailwind 3.4 | Tailwind 4.3 | Silencioso |
|---|---|---|---|
border sin color | gray-200 | currentColor | Sí |
ring | 3 px, blue-500 al 50 % | 1 px, currentColor | Sí |
shadow-sm | 0 1px 2px | 0 1px 3px | Sí |
rounded-sm | 2 px | 4 px | Sí |
blur-sm, backdrop-blur-sm | 4 px | 8 px | Sí |
outline-2 sola | Solo el ancho: no se ve (outline-style: none) | Se ve (solid) | Sí |
outline-none outline-2 | solid, 2 px | none | Sí |
outline-none | Contorno transparente, visible en colores forzados | Sin contorno | Sí |
drop-shadow-md/lg/xl | Dos sombras | Una sola sombra | Sí |
drop-shadow-sm | 0 1px 1px al 5 % | 0 1px 2px al 15 % | Sí |
drop-shadow | Dos sombras | Mismo valor que en v3: alias obsoleto | No |
bg-red-500 y toda la paleta | #ef4444 | #fb2c36, en oklch() | Sí |
text-sm | line-height en rem, un hijo con otro tamaño lo hereda: 20 px en un text-[32px] | Proporción sin unidad: 45,7 px en el mismo hijo | Sí |
shadow-lg/50 | No existe | La opacidad sustituye a la de la sombra | No |
12.2. Preflight
| Elemento | Tailwind 3.4 | Tailwind 4.3 | Cómo mantener lo de v3 |
|---|---|---|---|
| Color de borde | gray-200 | currentColor | Regla de compatibilidad de la sección 6 |
| Placeholder | gray-400 | Color actual al 50 % | placeholder:text-slate-400 |
| Cursor de botones | pointer | default | button:not(:disabled) { cursor: pointer; } en @layer base |
Atributo hidden | Sin !important: flex lo anula | Con !important: gana siempre | [&[hidden]]:hidden funciona en las dos |
Márgenes de <dialog> | Centrado (margin: auto) | margin: 0, pegado a la esquina | m-auto |
Márgenes de popover | Centrado | margin: 0 | m-auto |
Para el anillo, puedes recuperar el comportamiento de v3 de golpe. Medido: con esto, ring da 3 px en blue-500.
@theme {
--default-ring-width: 3px;
--default-ring-color: var(--color-blue-500);
}
Según la guía oficial, la regla de v4 excluye hidden="until-found".
12.3. Orden de las clases
Es la categoría más traicionera, y la guía oficial no la cuenta. v3 ordena las clases como texto y deja hidden al final; v4 ordena los números por valor y, entre clases de una misma propiedad, alfabéticamente. Cuando un elemento tiene dos clases rivales, puede cambiar el ganador.

Figura 14. El mismo HTML en las dos versiones, con clases rivales y valores en píxeles.
| Clases | Gana en v3 | Gana en v4 |
|---|---|---|
mt-2 mt-10 | mt-2, 8 px | mt-10, 40 px |
ml-6 ms-2 | ms-2, 8 px | ml-6, 24 px |
hidden flex | hidden | hidden |
hidden inline-flex | hidden | inline-flex |
fixed absolute | absolute | fixed |
md:block @md:flex | md:block | @md:flex |
hidden pierde en v4 frente a inline, inline-block, inline-flex, inline-grid, list-item y todas las de tabla. Por eso classList.add('hidden') sobre un elemento inline-flex deja de ocultarlo: es el fallo de nuestra etiqueta. Lo más seguro es usar el atributo, elemento.hidden = true, que gana en v4 y que con [&[hidden]]:hidden funciona en las dos.
También cambia el orden de tus clases propias: en v3, las de @layer utilities iban detrás de las nativas y ganaban siempre; en v4 se ordenan junto a ellas por propiedades y por nombre. Lo vimos en el módulo 15.
12.4. Variantes
| Cambio | Tailwind 3.4 | Tailwind 4.3 | Herramienta | Silencioso |
|---|---|---|---|---|
| Orden de apilado | first:*:pt-0 | *:first:pt-0 | Sí | Sí |
| Importante | !flex | flex!. En 3.4, flex! no se aplica | Sí | Sí |
hover: | También al tocar | Solo con @media (hover: hover) | No hace falta | Sí |
before:hover: | Funciona | No pinta nada: usa hover:before: | Sin medir | Sí |
open: | Solo [open] | También popover | No hace falta | No |
outline en focus: | Hay que escribir outline outline-2 | outline-2 basta | Sí | No |
Medimos el orden de apilado con first:*:pt-0 *:pt-4 y dos hijos: en v3 da 0 y 16 px, y en v4 da 16 y 16 px, porque first: se refiere al padre. La versión correcta en v4, *:first:pt-0, da 0 y 16 px. El detalle completo está en el módulo 11.
Si tu interfaz necesita el hover de siempre en pantallas táctiles, esto lo recupera, y lo hemos comprobado:
@custom-variant hover (&:hover);
12.5. Valores arbitrarios
| Cambio | Tailwind 3.4 | Tailwind 4.3 | Silencioso |
|---|---|---|---|
| Variables | bg-[--marca] | bg-(--marca). La antigua compila a CSS no válido y no pinta | Sí |
Comas en grid-cols-[...] | grid-cols-[1fr,2fr] da dos columnas | Da una columna: usa grid-cols-[1fr_2fr] | Sí |
Color oklch() propio con /50 | No admite el modificador: usa hexadecimal | Funciona | No |
| Variable con un degradado | bg-[image:var(--fondo)] | bg-(image:--fondo): bg-(--fondo) no pinta nada | Sí |
12.6. Transiciones y transformaciones
En v4, rotate-*, scale-* y translate-* escriben sus propias propiedades de CSS, no transform.
| Código de v3 | Efecto en v4 | Solución |
|---|---|---|
transition-[opacity,transform] hover:scale-110 | Salta de golpe: 0 animaciones activas, medido | transition-[opacity,scale]: 1 animación |
scale-110 transform-none | Sigue a 1,1: transform-none no toca scale | scale-none |
transition con un outline que cambia de color | transition incluye outline-color en v4 | Fija el color de contorno sin variante |
Una animación con transform | Se suma a tus clases en lugar de sustituirlas | Revisa los @keyframes |
Este error se escapa en una revisión visual, porque el estado final es correcto: lo único que falta es el camino. Busca transition-[ en todo el proyecto. El módulo 13 tiene los detalles, incluida la diferencia en la lista de transition: en v4 añade outline-color, los puntos de degradado, translate, scale, rotate, display y otras.
También cambia cómo se aplica la duración. En v3, cada transition-* fija sus 150 ms, y con duration-700 hover:transition-colors la duración en hover baja a 0,15 s; en v4 usa una variable, y los 0,7 s se mantienen.
12.7. space-* y divide-*

Figura 15. divide-y, space-x-*, apilado y degradados. La columna de v3 solo muestra una banda porque bg-linear-to-r no existe allí; en v4 las dos bandas son iguales.
| Clase | Selector en v3 | Selector en v4 | Efecto |
|---|---|---|---|
space-x-4 | Margen izquierdo a cada hijo menos el primero | Margen en cada hijo menos el último | En v3, la segunda fila de un flex-wrap se desplaza; en v4, no |
space-y-4 | Margen superior a cada hijo menos el primero | Margen inferior a cada hijo menos el último | Si reordenas u ocultas hijos, cambia qué elemento lleva el margen |
divide-y | Borde superior en cada hijo menos el primero | Borde inferior en cada hijo menos el último | Con el último hijo oculto, v4 pinta una línea sobrante bajo el último visible |
divide-y y border-t-0 en un hijo | No se anula | El hijo manda | En v3 hace falta !border-t-0 |
La guía oficial sugiere cambiar a gap-* si esto te da problemas, y es buen consejo: gap da el mismo resultado en las dos versiones.
12.8. Degradados
| Cambio | Tailwind 3.4 | Tailwind 4.3 |
|---|---|---|
| Nombre | bg-gradient-to-r | bg-linear-to-r (el antiguo sigue generándose) |
| Espacio de interpolación | sRGB | oklab, con los dos nombres: los tonos intermedios son distintos |
| Variante sobre un punto | hover:from-amber-500 reinicia el degradado: via se pierde | Solo cambia ese punto, y el via se mantiene. Usa via-none para quitarlo |
Medido con from-red-500 via-green-500 to-blue-500 hover:from-amber-500: al pasar el ratón, v3 da un degradado de dos colores, de ámbar a transparente, y v4 uno de tres, de ámbar a azul pasando por verde. Por la interpolación, el título de la oferta cambia ligeramente de tono: compáralo en las figuras 1 y 10. Renombrar la clase no lo resuelve, porque bg-gradient-to-r también interpola en oklab en v4.
12.9. Puntos de ruptura
| Cambio | Tailwind 3.4 | Tailwind 4.3 | Silencioso |
|---|---|---|---|
| Unidad | Píxeles, min-width: 768px | rem, width >= 48rem | No |
min-[900px]: junto a md: | Se ordena bien, gana el mayor | Sale antes que los puntos en rem: a 1000 px gana md: | Sí |
--breakpoint-* en px | No aplica | Igual: se ordenan antes de los que están en rem | Sí |
max-[900px]: en 900 px exactos | Incluido | Excluido | Sí |
@container | Plugin, se ordena antes que los medios | Nativo, después | Sí |
Medido con w-10 md:w-20 min-[900px]:w-40 a 1000 px: 160 px en v3 y 80 px en v4. Con min-[56.25rem]: el resultado vuelve a ser 160 px. Esta es la razón por la que corregimos --breakpoint-xs: 480px, que la herramienta copió tal cual. También hay sorpresas en v3 si usas objetos en screens, como vimos en el módulo 10.
12.10. Hojas de estilo
| Cambio | Tailwind 3.4 | Tailwind 4.3 | Silencioso |
|---|---|---|---|
| CSS propio sin capa | Las utilidades ganan a a { color: red } | Tu CSS gana: las capas nativas pierden frente a lo que no tiene capa | Sí |
@tailwind base | Necesario | @import "tailwindcss" | Sí: sin tema ni Preflight |
@layer components | Directiva de Tailwind: quita lo no usado y admite variantes | Capa real: se emite siempre y hover:boton no genera nada | Sí |
@apply boton | Admite clases propias | Error: Cannot apply unknown utility class | No |
@apply bg-opacity-50 | Funciona | El mismo error | No |
@screen md | Funciona | Pasa tal cual al CSS: el navegador lo ignora | Sí |
theme() en un plugin | theme('fontSize.2xl') da el tamaño | Da [tamaño, { lineHeight }]: usa .0 | Sí |
addComponents | Capa components, las utilidades ganan | Se ordena como una utilidad | Sí |
tailwind.config.js | Se detecta solo | Se ignora sin @config | Sí |
| Detección de contenido | content | Automática, con @source | No |
@apply en CSS Modules, Vue, Svelte | Funciona | Necesita @reference | No |
La primera fila es la que más sorprende. Medimos a { color: rgb(255,0,0); padding: 0 } h1 { font-size: 10px } frente a class="text-indigo-600 p-4" y class="text-4xl":
| Color del enlace | Relleno | Título | |
|---|---|---|---|
| v3, CSS antes de Tailwind | Índigo | 16 px | 36 px |
| v3, CSS después de Tailwind | Índigo | 16 px | 36 px |
| v4 | Rojo | 0 px | 10 px |
La solución es meter tu CSS en @layer base. Y no confíes en que la herramienta lo haga: en la tienda envolvió :root en una capa, pero en el otro proyecto dejó .nota sin capa.
@layer base {
a { color: var(--color-marca-700); }
}
12.11. Herramientas de alrededor
| Herramienta | Con v3 | Con v4 |
|---|---|---|
tailwind-merge | Rama 2 | Rama 3 |
tailwind-variants | 0.3 | 1 o posterior |
prettier-plugin-tailwindcss | tailwindConfig | tailwindStylesheet |
| Extensión de VS Code | Busca tailwind.config.js | Necesita saber cuál es tu hoja de entrada |
resolveConfig en JavaScript | Disponible | No existe: lee las variables con getComputedStyle |
El módulo 16 y el módulo 01 tienen los detalles. tailwind-merge es especialmente delicado: la rama 2 no entiende las clases de v4, así que hay que actualizarla junto con Tailwind.
13. Problemas frecuentes
| Síntoma | Causa probable | Solución |
|---|---|---|
La herramienta termina con Cannot find module 'tailwindcss/plugin' | Plugin propio con el envoltorio plugin() | git checkout ., npm ci, quita el envoltorio y repite |
Dice Upgrading from Tailwind CSS v4.x y no renombra casi nada | Ya la habías ejecutado | Vuelve al commit inicial |
Git directory is not clean | Cambios sin confirmar | Haz commit, no uses --force |
| “you need to create a CSS file” | Tus hojas son .scss o .less | Quita Sass antes, sección 9 |
| Un fondo translúcido se ve opaco | bg-opacity-* | Modificador /N |
| Aparecen elementos que estaban ocultos | hidden inline-flex, classList.add('hidden') | Atributo hidden o quita la clase rival |
| Aparecen elementos que debían verse | Atributo hidden con flex | Quita el atributo |
| Márgenes distintos sin razón | Dos clases rivales | Deja solo una |
| Un evento o una cadena deja de funcionar | La herramienta ha tocado un nombre | Lee el diff de JavaScript |
| Las clases del proyecto no aplican | tailwind.config.js sin @config | @config o migra a @theme |
| Tus estilos pisan las utilidades | CSS sin capa | @layer base |
@media a medida no funciona | @screen sin procesar | @variant md o @media (width >= theme(--breakpoint-md)) |
Un md: y un min-[...] se pisan | Mezcla de px y rem | Usa rem en todo |
| La animación salta de golpe | transition-[transform] | Lista scale, rotate o translate |
Un <dialog> queda en la esquina | Preflight de v4 | m-auto |
| Los bordes tienen el color del texto | Quitaste la compatibilidad | border-gray-200 donde haga falta |
El hover no funciona en el móvil | Es lo previsto en v4 | @custom-variant hover (&:hover); |
| Un botón no muestra la mano | cursor: default | Regla en @layer base |
| Clases nuevas no aparecen con Vite | Fichero creado con el servidor en marcha | Guarda app.css para que vuelva a escanear |
Cannot apply unknown utility class | @apply de clase propia o eliminada | @utility, o cambia la clase |
14. Lista de comprobación final
Antes de fusionar la rama:
git diffleído completo, sobre todo los.js,.tsy componentes.- Búsqueda de clases
*-opacity-*, también dentro de cadenas. - Búsqueda de
transition-[,transform-none,min-[,max-[,before:hover,after:hover. - Búsqueda de
classList.add('hidden')yhiddenjunto ainline*. - Búsqueda de dos clases rivales en un mismo elemento.
grid-cols-[con comas, yobject-[igual.- Comprobación de cada
bordersin color, si quitas la compatibilidad. - CSS sin capa.
- Clases construidas dinámicamente, que ninguna herramienta puede ver.
- Capturas comparadas con la línea base, en claro y oscuro, y a varios anchos.
- Interacciones: abrir modales, pasar el ratón, enfocar con el tabulador.
tailwind-merge, Prettier y extensión del editor actualizados.- Prueba en la versión mínima de cada navegador: Safari 16.4, Chrome 111, Firefox 128.
15. Ejercicios
Ejercicio 1: Clasifica los cambios
Sin ejecutar nada, indica qué ocurre con cada línea al pasar por npx @tailwindcss/upgrade, y si necesita arreglo manual:
<div class="shadow rounded bg-black bg-opacity-40"></div>
<span class="hidden inline-flex"></span>
<a class="outline-none focus:ring"></a>
<div class="first:*:pt-0 transition-[transform] hover:scale-105"></div>
Ver solución
| Clase | Herramienta | A mano |
|---|---|---|
shadow rounded | Pasan a shadow-sm rounded-sm | Nada |
bg-black bg-opacity-40 | Sin cambios | bg-black/40 |
hidden inline-flex | Sin cambios | Quita una de las dos, o inline-flex ganará |
outline-none | Pasa a outline-hidden | Nada |
focus:ring | Pasa a focus:ring-3 | Añade el color, como focus:ring-blue-500/50 |
first:*:pt-0 | Pasa a *:first:pt-0 | Nada |
transition-[transform] | Sin cambios | transition-[scale] |
Los cuatro elementos dejan algo pendiente, aunque la herramienta termine sin un solo aviso.
Ejercicio 2: Renombrar sin herramienta
Tienes que renombrar shadow y shadow-sm con búsqueda y sustitución en un editor, sin usar npx @tailwindcss/upgrade. ¿En qué orden? ¿Qué patrón evita tocar shadow-md y box-shadow?
Ver solución
Primero la más pequeña, porque si no la sustitución se aplica dos veces:
shadow-smpasa ashadow-xs.shadowpasa ashadow-sm.
Si lo haces al revés, shadow pasa a shadow-sm, y luego todos los shadow-sm pasan a shadow-xs, tanto los originales como los recién creados.
La expresión del segundo paso debe exigir que el nombre sea una clase completa y sin guion a los lados:
(?<![\w-])shadow(?![\w-])
Con (?<![\w-]) evitas box-shadow, y con (?![\w-]) evitas shadow-md. Aun así, const shadow también coincidiría: una búsqueda así no distingue clases de variables, y por eso hay que revisar cada cambio en JavaScript.
Ejercicio 3: De tailwind.config.js a @theme
Traduce a CSS esta configuración:
export default {
theme: {
extend: {
colors: { marca: { 500: '#0f766e' } },
fontFamily: { titulo: ['Georgia', 'serif'] },
screens: { tablet: '900px' },
borderRadius: { tarjeta: '0.75rem' },
boxShadow: { tarjeta: '0 8px 24px rgb(0 0 0 / 0.12)' },
keyframes: { latido: { '50%': { opacity: '.5' } } },
animation: { latido: 'latido 2s infinite' },
},
},
};
Ver solución
@import "tailwindcss";
@theme {
--color-marca-500: #0f766e;
--font-titulo: Georgia, serif;
--breakpoint-tablet: 56.25rem;
--radius-tarjeta: 0.75rem;
--shadow-tarjeta: 0 8px 24px rgb(0 0 0 / 0.12);
--animate-latido: latido 2s infinite;
@keyframes latido {
50% { opacity: 0.5; }
}
}
Hemos pasado 900px a 56.25rem para no romper el orden de los puntos de ruptura. Comprobado con bg-marca-500/50 font-titulo rounded-tarjeta shadow-tarjeta animate-latido tablet:flex: el color admite opacidad, el radio mide 12 px, la animación se llama latido, y a 1000 px de ancho el elemento es flex.
Ejercicio 4: Repara la cabecera
Esta línea se ve mal tras migrar: el botón es blanco y el anillo de foco, negro. Arréglala para que se vea como en v3.
<button class="rounded-sm bg-white bg-opacity-20 px-3 py-1 hover:bg-opacity-30 focus:outline-hidden focus:ring-3">Cesta</button>
Ver solución
<button class="rounded-sm bg-white/20 px-3 py-1 hover:bg-white/30 focus:outline-hidden focus:ring-3 focus:ring-blue-500/50">Cesta</button>
bg-opacity-* no existe en v4, y hover:bg-opacity-30 necesita repetir el color porque ahora la opacidad forma parte de él. ring-3 usa currentColor, así que hay que indicar el color. Medido: el fondo pasa de opaco a blanco al 20 %, como en v3.
Ejercicio 5: Un componente con variante
En v3 usabas hover:boton. Tras migrar a mano dejas esto, y al pasar el ratón no ocurre nada. ¿Por qué? Arréglalo.
@import "tailwindcss";
@layer components {
.boton { padding: 1rem; color: red; }
}
Ver solución
En v4, @layer components es una capa nativa de CSS, y lo que hay dentro es CSS normal: Tailwind no lo conoce y no puede crear hover:boton. Medido: no se genera ninguna regla. La solución es registrarla como utilidad:
@import "tailwindcss";
@utility boton {
padding: 1rem;
color: red;
}
Ahora se genera .hover\:boton:hover, dentro de @media (hover: hover), y también puedes usar @apply boton.
Ejercicio 6: La etiqueta que no se oculta
Este código ocultaba el descuento en v3 y en v4 deja de hacerlo. Explica por qué y da dos soluciones.
<span id="etiqueta" class="inline-flex bg-red-500/90 px-2">-20 %</span>
<script>
document.querySelector('#etiqueta').classList.add('hidden');
</script>
Ver solución
Entre clases de display, v4 ordena alfabéticamente, y inline-flex sale después de hidden, así que gana. En v3, hidden iba al final y ganaba siempre.
Solución 1, con el atributo. Preflight de v4 le da !important. Medido: display: none.
document.querySelector('#etiqueta').hidden = true;
Solución 2, cambiando una clase por la otra:
document.querySelector('#etiqueta').classList.replace('inline-flex', 'hidden');
Si el código debe funcionar en las dos versiones, usa el atributo y añade [&[hidden]]:hidden al elemento. Medido en v4: display: none.
Ejercicio 7: El punto de ruptura que no manda
A 1000 px, este elemento mide 160 px en v3 y 80 px en v4. Da dos arreglos.
<div class="w-10 md:w-20 min-[900px]:w-40"></div>
Ver solución
En v4, los valores en píxeles se ordenan antes que los puntos en rem, de modo que md:w-20 sale después y gana.
Arreglo 1: escribe el valor en rem.
<div class="w-10 md:w-20 min-[56.25rem]:w-40"></div>
Arreglo 2: crea un punto de ruptura, también en rem.
@theme {
--breakpoint-tablet: 56.25rem;
}
<div class="w-10 md:w-20 tablet:w-40"></div>
Medidos los dos a 1000 px: 160 px. Recuerda que 56,25 rem son 900 px con el tamaño de letra por defecto del navegador.
Ejercicio 8: La animación perdida
Al pasar el ratón sobre esta tarjeta, en v3 crece suavemente, y en v4 salta. Corrígelo y explica cómo lo comprobarías.
<div class="transition-[opacity,transform] duration-[2000ms] hover:scale-150">Tarjeta</div>
Ver solución
hover:scale-150 escribe la propiedad scale, que no está en la lista de transition-[...]:
<div class="transition-[opacity,scale] duration-[2000ms] hover:scale-150">Tarjeta</div>
Para comprobarlo, pasa el ratón y pregunta al navegador por las animaciones activas:
elemento.getAnimations().length
Medido a los 100 ms del hover: 0 con el código original y 1 con el corregido.
Ejercicio 9: Un auditor de migración
Escribe un guion de Node que recorra ficheros y avise de lo que npx @tailwindcss/upgrade deja sin resolver. Pruébalo con el estado de nuestra tienda justo después de la herramienta.
Ver solución
// auditar.mjs. Uso: node auditar.mjs index.html src
import fs from 'node:fs';
import path from 'node:path';
const reglas = [
[/\b[a-z-]+-opacity-\d+/, 'Opacidad eliminada: usa el modificador /N'],
[/transition-\[[^\]]*transform/, 'scale-*, rotate-* y translate-* ya no son transform'],
[/\btransform-none\b/, 'No anula scale-*: usa scale-none, rotate-none'],
[/\bhidden\b[^"'`]*\b(inline|inline-flex|inline-block|inline-grid|list-item|table[a-z-]*)\b/, 'hidden pierde frente a esta clase'],
[/\b(inline|inline-flex|inline-block|inline-grid|list-item|table[a-z-]*)\b[^"'`]*\bhidden\b/, 'hidden pierde frente a esta clase'],
[/classList\.add\(\s*['"]hidden['"]/, 'Comprueba que el elemento no sea inline-flex'],
[/\b(min|max)-\[\d+(\.\d+)?px\]:/, 'Orden y frontera cambian: pásalo a rem'],
[/--breakpoint-[\w-]+:\s*[\d.]+px/, 'Puntos de ruptura en px: pásalos a rem'],
[/\bbefore:(hover|focus|active):|\bafter:(hover|focus|active):/, 'Invierte el orden: hover:before:'],
[/\[[^\]\s]*\d(fr|px|rem|%),[^\]]*\]/, 'Las comas ya no separan: usa _'],
[/new Event\(['"][a-z]+-(xs|sm)['"]/, 'La herramienta ha tocado un nombre de evento'],
[/\bm[tblrxy]-\d+\b[^"'`]*\bm[tblrxy]-\d+\b/, 'Revisa dos clases del mismo margen'],
];
function* ficheros(ruta) {
if (fs.statSync(ruta).isDirectory()) {
for (const f of fs.readdirSync(ruta)) yield* ficheros(path.join(ruta, f));
} else if (/\.(html|js|jsx|ts|tsx|vue|css)$/.test(ruta)) {
yield ruta;
}
}
let total = 0;
for (const entrada of process.argv.slice(2)) {
for (const f of ficheros(entrada)) {
fs.readFileSync(f, 'utf8').split('\n').forEach((linea, i) => {
for (const [patron, consejo] of reglas) {
const m = linea.match(patron);
if (m) {
total++;
console.log(`${f}:${i + 1} ${m[0].slice(0, 40)} -> ${consejo}`);
}
}
});
}
}
console.log(`${total} avisos`);
Salida real sobre la tienda justo después de la herramienta:
index.html:17 bg-opacity-20 -> Opacidad eliminada: usa el modificador /N
index.html:35 transition-[opacity,transform -> scale-*, rotate-* y translate-* ya no son transform
index.html:41 mt-2 mt-10 -> Revisa dos clases del mismo margen
index.html:46 bg-opacity-90 -> Opacidad eliminada: usa el modificador /N
index.html:50 mt-2 ml-6 -> Revisa dos clases del mismo margen
index.html:57 hidden inline -> hidden pierde frente a esta clase
index.html:83 max-[900px]: -> Orden y frontera cambian: pásalo a rem
index.html:85 ring-opacity-50 -> Opacidad eliminada: usa el modificador /N
index.html:108 bg-opacity-50 -> Opacidad eliminada: usa el modificador /N
src\app.js:14 classList.add('hidden' -> Comprueba que el elemento no sea inline-flex
src\app.js:20 classList.add('hidden' -> Comprueba que el elemento no sea inline-flex
src\app.js:25 bg-opacity-90 -> Opacidad eliminada: usa el modificador /N
src\app.js:26 bg-opacity-90 -> Opacidad eliminada: usa el modificador /N
src\app.js:39 new Event('blur-sm' -> La herramienta ha tocado un nombre de evento
src\app.js:45 bg-opacity-50 -> Opacidad eliminada: usa el modificador /N
15 avisos
Sobre la tienda corregida da un solo aviso, el de classList.add('hidden') del modal, que es correcto porque ese elemento alterna hidden y flex. El guion muestra solo la primera clase de opacidad de cada línea, y se equivoca con las clases rivales de distinto eje, como mt-2 ml-6. Es un ayudante para mirar, no un veredicto.
Ejercicio 10 (propuesto): Un proyecto con prefijo
Crea un proyecto v3 con prefix: 'tw-' y clases como tw-shadow-sm, tw-rounded-sm y hover:tw-ring. Ejecuta la herramienta y comprueba cuáles renombra. Decide qué harías con las que no.
Ejercicio 11 (propuesto): Migra algo tuyo
Coge un proyecto v3 tuyo, o el de un compañero, y sigue la secuencia completa: línea base, rama, herramienta, diff, auditoría, reparación y comparación. Anota qué fallos ha detectado solo la comparación visual.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Decidir | Navegadores, Sass, plugins | Safari 16.4, Chrome 111, Firefox 128. Quedarse en 3.4 puede ser lo correcto |
| Preparar | Rama, commit, capturas | Sin línea base no verás los fallos silenciosos |
| Herramienta | npx @tailwindcss/upgrade | Node 20+, Git limpio, seis fases |
| Fallo con plugin | tailwindcss/plugin | Quita el envoltorio; no repitas sobre el resultado |
| Repetir la orden | Upgrading from v4.x | Solo moderniza: no renombra escalas |
| Configuración | @theme, @plugin, @custom-variant | Se traduce si es sencilla; si no, queda con @config |
| Ignoradas | corePlugins, safelist, separator | Sin aviso. safelist pasa a @source inline() |
| Opacidad | bg-black/50 | bg-opacity-* no se convierte: búscala siempre |
| Errores de la herramienta | new Event('blur-sm') | Lee el diff de JavaScript |
| Escalas | shadow-xs, rounded-xs, blur-xs | Renombra de la pequeña a la grande |
| Falsos amigos | border, ring, outline-2, drop-shadow-md | Existen en las dos versiones y dan otra cosa |
| Orden | mt-2 mt-10, hidden inline-flex | Dos clases rivales pueden cambiar de ganador |
| Preflight | cursor, placeholder, dialog, hidden | Cambian sin renombrar nada |
| Variantes | *:first:, flex!, hover:before: | Se leen de izquierda a derecha |
| Arbitrarios | bg-(--x), grid-cols-[1fr_2fr] | Paréntesis para variables, _ para espacios |
| Transformaciones | transition-[scale], scale-none | Ya no son transform |
| Puntos de ruptura | min-[56.25rem]: | Todo en rem |
| Capas | @layer base, @utility | El CSS sin capa gana a las utilidades |
| Vite | @tailwindcss/vite | La herramienta deja PostCSS: cámbialo tú |