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/upgrade sobre 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/50 y gap-*, 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:

  1. Comprueba Node. La herramienta exige Node 20 o superior. Solo la herramienta: tu proyecto puede no usarlo después.
  2. Termina lo que tengas a medias y haz commit. Con cambios sin confirmar, la herramienta se niega a trabajar.
  3. Crea una rama propia, git switch -c migracion-tailwind-4.
  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.
  5. 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-v3 nos 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:

Tienda EMPRESA en Tailwind 3.4: cabecera verde, oferta, tres tarjetas, pedidos, formulario y garantía

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

La misma tienda en modo oscuro con Tailwind 3.4

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 --force para quitarte el aviso de encima. El aviso existe para que git diff muestre 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.

Seis fases: comprobar, configuración, hojas, dependencias, plantillas y PostCSS. Una nota señala que la primera ejecución se rompió en plantillas, con hojas y dependencias ya cambiadas

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 . y npm ci, comprueba que tailwindcss vuelve 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:

  • .boton y .boton-peligro pasan de @layer components a @utility. Sin esto, @apply boton daría un error en v4.
  • Dentro del @apply ha renombrado rounded a rounded-sm y shadow-sm a shadow-xs, en el orden correcto.
  • theme('colors.marca.900') pasa a var(--color-marca-900), y el valor sigue siendo el mismo.
  • @screen md, que v4 dejaría pasar sin procesar, se convierte en una consulta @media real.
  • 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.

En v3 shadow-sm y shadow; en v4 esos mismos valores se llaman shadow-xs y shadow-sm, y shadow-md no cambia

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.

Tienda tras la herramienta: el botón de la cesta es un rectángulo blanco sin texto, aparece la etiqueta Agotado, y hay márgenes distintos en los precios

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:

Modal de la cesta en v3 sobre fondo translúcido

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

Modal de la cesta tras la herramienta, sobre fondo completamente negro

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

La tienda en modo oscuro tras la herramienta, con el botón de la cesta blanco

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 diff de ficheros que no sean CSS es obligatorio. Fíjate en que addEventListener('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

Tres columnas: lo que hizo la herramienta, lo que tapó con compatibilidad o hizo mal, y lo que quedó a tu cargo

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

Tienda corregida, prácticamente idéntica a la versión v3

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

Modal corregido sobre fondo translúcido

Figura 11. El modal recupera la transparencia.

Tienda corregida en modo oscuro

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: @config es 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:

  1. Instala tailwindcss y el integrador: @tailwindcss/vite, @tailwindcss/postcss o @tailwindcss/cli.
  2. Sustituye las tres directivas @tailwind por @import "tailwindcss";. Quita autoprefixer y postcss-import.
  3. Conecta tu configuración sin traducirla: @config "../tailwind.config.js";. Con eso ya compila.
  4. Añade la compatibilidad de bordes, anillos y botones de la sección 12.2 para no ver cambios todavía.
  5. Convierte @layer components y @layer utilities a @utility donde uses @apply de esas clases o variantes como hover:boton.
  6. Renombra las clases de las escalas, de la pequeña a la grande (sección 11).
  7. Elimina *-opacity-*, y arregla el orden de las clases rivales.
  8. Mueve el tema a @theme por bloques, comprobando cada uno, y quita @config al 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.

Comparativa: en v4 el borde es rojo, el anillo es negro y fino, outline-2 se ve, el texto desenfocado es más difuso y el rojo es más saturado

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.

Comparativa: en v4 aparece Agotado, el bloque baja más, se desplaza más a la derecha, la barra es más corta y las columnas se apilan

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-*

Comparativa: en v4 hay una línea roja bajo Dos, la fila de c queda alineada, cambia el relleno del primer párrafo y aparecen dos bandas de degradado

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:

  1. git diff leído completo, sobre todo los .js, .ts y componentes.
  2. Búsqueda de clases *-opacity-*, también dentro de cadenas.
  3. Búsqueda de transition-[, transform-none, min-[, max-[, before:hover, after:hover.
  4. Búsqueda de classList.add('hidden') y hidden junto a inline*.
  5. Búsqueda de dos clases rivales en un mismo elemento.
  6. grid-cols-[ con comas, y object-[ igual.
  7. Comprobación de cada border sin color, si quitas la compatibilidad.
  8. CSS sin capa.
  9. Clases construidas dinámicamente, que ninguna herramienta puede ver.
  10. Capturas comparadas con la línea base, en claro y oscuro, y a varios anchos.
  11. Interacciones: abrir modales, pasar el ratón, enfocar con el tabulador.
  12. tailwind-merge, Prettier y extensión del editor actualizados.
  13. 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:

  1. shadow-sm pasa a shadow-xs.
  2. shadow pasa a shadow-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ú